SSD Nodes Learn 🎉 VPS $5.50/월부터
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-13

AFFiNE 직접 호스팅: Docker Compose 구축 가이드

Docker Compose를 사용하여 AFFiNE을 VPS에 직접 설치하는 방법을 설명합니다. 4개의 컨테이너 구성, 데이터 저장 경로, 2GB RAM 환경에서의 성능 제약 및 안정적인 백업 전략을 포함한 실무 가이드를 확인하여 Notion 대안을 직접 운영해 보시기 바랍니다.

AFFiNE을 직접 호스팅할 때 얻는 것

AFFiNE을 직접 호스팅하면 사용자가 제어하는 서버에서 Notion 스타일의 작업 공간을 운영할 수 있습니다. 이 환경은 애플리케이션, 1회성 마이그레이션 작업, Postgres, Redis라는 4개의 컨테이너로 구성됩니다. 실시간 협업 기능이 포함되어 있으며, 직접 호스팅하는 작업 공간은 기본적으로 최대 10개의 좌석을 제공합니다. 설치는 1개의 compose 파일과 1개의 JSON 설정 파일로 이루어집니다. 이미지 태그, 디스크 레이아웃, 메모리 제한, 그리고 앞단에 배치할 프록시 설정에 대해 고려해야 합니다.

AFFiNE은 문서 편집기와 무한 캔버스를 동일한 작업 공간 내에서 제공하므로, 하나의 페이지를 문서로 읽거나 화이트보드처럼 펼쳐서 볼 수 있습니다. 무엇을 실행할지 아직 결정하지 못했다면 먼저 직접 호스팅 가능한 Notion 대안 비교를 읽어보시기 바랍니다. 이 가이드는 이미 선택을 마친 사용자를 대상으로 하며, 비교보다는 AFFiNE을 올바르게 실행하는 방법에 중점을 둡니다.

이 문서의 모든 내용은 2026년 8월 8일 기준 AFFiNE 직접 호스팅 문서 및 공개된 릴리스 파일을 바탕으로 검증되었습니다. 해당 날짜 기준 최신 안정화 릴리스는 2026년 7월 23일에 배포된 0.27.3 버전입니다.

네 개의 컨테이너가 수행하는 역할

affine은 서버와 웹 클라이언트를 하나의 이미지로 통합한 것입니다. 이 컨테이너는 3010 포트에서 대기합니다.

affine_migrationnode ./scripts/self-host-predeploy.js을 실행하고 데이터베이스 마이그레이션을 적용한 뒤 종료되는 일회성 작업입니다. 애플리케이션은 해당 작업에 condition: service_completed_successfully를 선언하므로, 마이그레이션이 0이 아닌 상태 코드로 종료되면 affine은 아예 시작되지 않습니다. 웹 인터페이스가 나타나지 않는다면 가장 먼저 해당 작업의 로그를 확인해야 합니다.

postgres은 문서, 사용자, 작업 공간 및 권한 정보를 보관합니다. 제공되는 이미지는 pgvector/pgvector:pg16이며, 이는 pgvector 확장 기능이 컴파일된 일반적인 Postgres 16입니다. pgvector는 Postgres에 vector 열 유형을 추가합니다. 이는 텍스트를 의미 기반으로 검색할 수 있도록 임베딩을 저장하는 데 사용되는 숫자 형식입니다.

redis는 필수 의존 요소입니다. 서버와 마이그레이션 작업 모두 이 컨테이너의 상태 확인이 완료될 때까지 대기한 후 시작됩니다. 제공된 compose 파일에서 Redis에 볼륨을 할당하지 않았다는 점에 주목하십시오. Redis 내부의 데이터는 docker compose down가 발생하면 모두 사라집니다. 이는 Redis가 사용자의 콘텐츠를 저장하지 않으며 백업이 필요 없음을 명확히 보여줍니다.

Postgres 이미지가 기본 postgres가 아닌 pgvector인 이유

이 요구 사항은 선호도가 아닌 AFFiNE의 스키마에서 비롯된 것입니다. schema.prisma에서 데이터 소스는 extensions = [pgvector(map: "vector")]을 선언하며, 4개의 테이블이 vector(1024) 타입의 embedding 컬럼을 포함합니다. 마이그레이션 작업은 AI 기능 활성화 여부와 관계없이 해당 테이블을 생성하므로, 마이그레이션이 완료되려면 데이터베이스에 확장이 미리 존재해야 합니다. postgres:16으로 교체하면 확장이 사라지고 마이그레이션이 해당 컬럼을 생성할 수 없게 되어, 서버는 실패한 작업을 기다리며 대기 상태에 빠집니다.

AFFiNE은 0.21 버전부터 pgvector 이미지로 전환했습니다. 해당 버전보다 이전 설치 환경에서는 이미지 라인을 수정하는 것만으로는 업그레이드가 완료되지 않으므로, 이미지를 가져오기 전에 AFFiNE 셀프 호스팅 문서의 업그레이드 페이지를 읽어보시기 바랍니다.

태그와 관련하여 한 가지 더 주의할 점이 있습니다. pg16은 Postgres 16을 의미하며, Postgres 메이저 버전은 임의로 올릴 수 있는 숫자가 아닙니다. 기존 데이터 디렉터리 위에서 pg17로 변경하면 Postgres는 시작을 거부하며, docker compose logs postgresThe data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17과 같은 메시지가 기록됩니다. 메이저 버전을 올리려면 덤프를 생성한 뒤 새로운 데이터 디렉터리에 복원하는 과정이 필요합니다.

자체 호스팅 AFFiNE은 CPU와 RAM을 얼마나 필요로 하는가

AFFiNE의 요구 사항 페이지에서는 최소 4개의 CPU 코어와 2 GB의 RAM을 권장하며, 문서가 10,000 단어를 넘어가면 메모리를 4 GB로 증설할 것을 명시합니다. 같은 페이지에서 메모리 사용처를 동기화 시스템과 문서 병합 작업으로 설명합니다. 기억해야 할 중요한 수치는 10,000개의 수정 사항이 포함된 문서를 병합할 때 최대 1 GB의 메모리가 필요할 수 있다는 점입니다.

이제 2명의 사용자가 작성 중인 2 GB 플랜의 서버를 생각해 보십시오. 평균적인 상황에서는 문제가 없습니다. Postgres와 Node 프로세스는 제한 범위 내에서 여유 있게 동작합니다. 문제는 최대 부하 시점입니다. 대규모 병합 작업이 발생하면 기존 상주 메모리에 더해 1 GB를 추가로 요구할 수 있습니다. 스왑(swap)이 없는 2 GB 서버에서는 커널의 OOM(out-of-memory) killer가 이 요청에 대응하여 가장 큰 프로세스인 AFFiNE 서버를 강제 종료합니다.

동료 사용자는 오류 메시지를 보지 못합니다. restart: unless-stopped가 몇 초 안에 컨테이너를 다시 시작하므로 페이지가 새로고침되는 현상만 경험하게 됩니다. 추측하지 말고 다음 명령어로 확인하십시오.

docker inspect affine_server --format '{{.State.OOMKilled}} {{.RestartCount}}'
sudo dmesg -T | grep -i -E 'out of memory|killed process'

첫 번째 명령의 true 결과나 두 번째 명령에서 node을 명시하는 Killed process 라인이 나타난다면, 버그가 아니라 메모리 부족 현상입니다. 양쪽에서 해결책을 적용하십시오. 먼저 스왑을 추가하여 메모리 급증 시 시스템이 즉시 중단되는 대신 느려지도록 만듭니다.

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

이제 free -h 명령을 실행하면 총 2.0Gi의 스왑이 보고되어야 합니다. 스왑은 AFFiNE을 빠르게 만들지 않으며, 그럴 목적으로 사용하는 것도 아닙니다. 스왑은 1초간의 메모리 급증 상황에서 컨테이너가 죽는 대신 잠시 느려지게 만듭니다. 다른 해결책은 병합 시점에 애플리케이션이 필요로 하는 공간을 Postgres 캐시가 점유하지 못하도록 제한하는 것입니다. 이는 Compose 서비스의 메모리 제한 설정을 통해 수행합니다.

스토리지 용량은 예측하기 훨씬 쉽습니다. 다음은 AFFiNE이 동일한 페이지에서 공개한 수치입니다.

ChartPublished AFFiNE storage figures, August 2026
The data behind this chart
[
  {
    "label": "Server install",
    "gb": 1.5
  },
  {
    "label": "Postgres per 1,000 docs",
    "gb": 0.1
  },
  {
    "label": "Blob store per 1,000 uploads",
    "gb": 10
  }
]

서버 설치에는 1.5 GB가 필요합니다. 약 1,000 단어로 구성된 문서 1,000개는 0.1 GB의 Postgres 데이터를 추가하며, 이는 거의 무시할 수 있는 수준입니다. 1,000개의 업로드된 파일은 10 GB를 차지하며, 이것이 전체 용량의 핵심입니다. 이 수치는 실제 운영 중인 인스턴스에서 측정한 값이 아니라 계획을 위한 참고 수치이므로, 절대적인 약속이 아닌 경향성으로 이해해야 합니다. 핵심은 데이터베이스는 작게 유지되고, 디스크 사용량은 업로드하는 파일에 의해 결정된다는 점입니다.

직접 compose 파일을 작성하고 태그 고정하기

문서화된 설치 방식은 curl -L -o docker-compose.yml https://github.com/toeverything/AFFiNE/releases/latest/download/docker-compose.yml을 사용하여 미리 만들어진 파일을 다운로드합니다. 이 방식도 작동하지만, 의존하기 전에 알아두어야 할 세부 사항이 있습니다. 2026년 8월 8일 기준으로 릴리스 0.27.3에 첨부된 파일은 여전히 .env 파일을 통해 경로를 읽으며 ${UPLOAD_LOCATION}, ${CONFIG_LOCATION}, ${DB_DATA_LOCATION}를 사용합니다. 반면 문서의 참조 페이지는 모든 것을 ./data 아래에 유지하며 .env이 전혀 필요 없는 최신 레이아웃을 보여줍니다. 두 방식 모두 정상입니다. 파일을 직접 작성하면 이 문제가 해결되며, 어차피 이미지를 고정하고 데이터베이스 비밀번호를 설정하려면 파일을 수정해야 합니다.

mkdir -p ~/affine/config ~/affine/data
cd ~/affine
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
chmod 600 .env

Compose는 프로젝트 디렉터리에서 .env을 자동으로 읽고 ${DB_PASSWORD}을 대신 치환하므로, 지원 스레드에 붙여넣을 파일에 비밀번호가 노출되지 않습니다. 이 습관은 운영하는 모든 스택에 걸쳐 유지할 가치가 있으며, 그 이유는 compose 파일에서 비밀을 분리하는 방법에 설명되어 있습니다.

이제 ~/affine/docker-compose.yml를 작성합니다:

name: affine
services:
  affine:
    image: ghcr.io/toeverything/affine:stable
    container_name: affine_server
    ports:
      - '127.0.0.1:3010:3010'
    depends_on:
      redis:
        condition: service_healthy
      postgres:
        condition: service_healthy
      affine_migration:
        condition: service_completed_successfully
    volumes:
      - ./data/storage:/root/.affine/storage
      - ./config:/root/.affine/config
    environment:
      - REDIS_SERVER_HOST=redis
      - DATABASE_URL=postgresql://affine:${DB_PASSWORD}@postgres:5432/affine
      - AFFINE_INDEXER_ENABLED=false
    restart: unless-stopped

  affine_migration:
    image: ghcr.io/toeverything/affine:stable
    container_name: affine_migration_job
    command: ['sh', '-c', 'node ./scripts/self-host-predeploy.js']
    volumes:
      - ./data/storage:/root/.affine/storage
      - ./config:/root/.affine/config
    environment:
      - REDIS_SERVER_HOST=redis
      - DATABASE_URL=postgresql://affine:${DB_PASSWORD}@postgres:5432/affine
      - AFFINE_INDEXER_ENABLED=false
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  redis:
    image: redis:8-alpine
    container_name: affine_redis
    healthcheck:
      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  postgres:
    image: pgvector/pgvector:pg16
    container_name: affine_postgres
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: affine
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: affine
      POSTGRES_INITDB_ARGS: '--data-checksums'
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'affine', '-d', 'affine']
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

업스트림에서 제공하는 파일과 네 가지 차이점이 있으며, 각각 이유가 있습니다.

  • 127.0.0.1:3010:3010은 루프백 주소에만 포트를 게시하므로, 결정하기 전까지는 서버 외부에서 AFFiNE에 접근할 수 없습니다. 업스트림의 '3010:3010'은 모든 인터페이스에 바인딩되며, 대부분의 VPS 이미지에서는 공용 인터페이스가 포함됩니다.
  • POSTGRES_HOST_AUTH_METHOD: trust는 제거되었고 대신 비밀번호가 설정되었습니다. Trust 인증은 해당 데이터베이스에 대한 모든 연결을 비밀번호 없이 affine 사용자로 허용합니다. 이는 사설 Compose 네트워크로 제한되지만, 디버깅 중에 다른 컨테이너를 해당 네트워크에 연결하거나 5432 포트를 게시하는 날에는 문제가 될 수 있습니다.
  • redis:8-alpine는 단순히 redis로 표기된 것을 대체하며, 이는 latest으로 해석됩니다. 2026년 8월 기준으로 이는 Redis 8이므로, 태그를 고정하면 테스트한 메인 버전을 유지할 수 있고 관련 없는 docker compose pull 중에 Redis 9가 예기치 않게 설치되는 것을 방지합니다.
  • pgvector/pgvector:pg16은 앞서 언급한 이유로 업스트림 설정 그대로 유지합니다.

POSTGRES_PASSWORD는 Postgres가 처음으로 데이터 디렉터리를 생성할 때만 읽힙니다. 이미 존재하는 인스턴스라면 docker compose exec postgres psql -U affine -c "ALTER USER affine WITH PASSWORD 'yourpassword'"으로 비밀번호를 설정한 다음 DATABASE_URL을 일치하도록 업데이트하십시오.

설정은 config/config.json에 위치합니다

AFFiNE은 config/config.json에서 설정을 읽어오며, 이는 /root/.affine/config에 마운트한 디렉터리입니다. 해당 파일은 자동으로 생성되지 않으므로 첫 실행 전에 직접 작성해야 합니다. ~/affine/config/config.json를 편집기로 열고 예시 도메인 대신 실제 도메인을 사용하여 다음 내용을 입력하십시오:

{
  "$schema": "https://github.com/toeverything/affine/releases/latest/download/config.schema.json",
  "server": {
    "name": "Team workspace",
    "externalUrl": "https://affine.example.com"
  },
  "copilot": {
    "enabled": false,
    "byok": {
      "enabled": false
    }
  }
}

server.externalUrl는 사용자가 브라우저에서 실제로 접속하는 주소여야 합니다. AFFiNE은 이 값을 기반으로 공유 링크와 워크스페이스 초대장을 생성하므로, http://localhost:3010으로 그대로 두면 사용자가 보낸 초대장을 받은 사람은 자신의 로컬 환경으로 연결되어 접속에 실패하게 됩니다. 파일과 관리자 패널 간의 불일치를 방지하기 위해 첫 실행 전에 반드시 공용 HTTPS 주소로 설정하십시오.

copilot은 AI 기능을 제어합니다. copilot.byok.enabled은 사용자가 직접 키를 입력하는(bring-your-own-key) 옵션으로, 워크스페이스 소유자가 자신의 모델 제공자 키를 워크스페이스 설정에 입력할 수 있게 합니다. AFFiNE을 자체 호스팅하는 경우 AI 구독은 포함되지 않습니다. AI 기능을 사용하지 않으려면 두 항목 모두 false로 두십시오.

스택을 시작합니다:

docker compose up -d
docker compose ps

docker compose ps 명령을 실행하면 affine_postgresaffine_redis가 healthy 상태로, affine_server이 running 상태로, 그리고 affine_migration_jobexited (0) 상태로 표시되어야 합니다. 마이그레이션 작업에서 다른 종료 코드가 발생하면 해당 로그를 확인하여 중단된 단계를 파악해야 합니다:

docker compose logs affine_migration

잊기 전에 이미지를 고정하십시오

stable은(는) 계속 변하는 태그입니다. AFFiNE의 릴리스 워크플로우는 여러 태그를 각 안정화 빌드에 지정하며, 이 중 두 가지가 중요합니다. stable은(는) 릴리스마다 가리키는 대상이 바뀌며, stable- 뒤에 git short hash가 붙는 태그는 그렇지 않습니다. stable 상태로 두면, 6개월 뒤에 실행하는 docker compose pull은(는) 다른 이미지를 가져와 사용자가 의도하지 않은 시점에 데이터베이스 마이그레이션을 수행하게 됩니다. 테스트를 마친 정확한 이미지를 고정하십시오.

docker compose pull
docker image inspect ghcr.io/toeverything/affine:stable --format '{{index .RepoDigests 0}}'

이 명령은 ghcr.io/toeverything/affine@sha256: 뒤에 긴 해시가 붙은 형태의 문자열을 출력합니다. 전체 문자열을 복사하여 affine과(와) affine_migration 모두image: 줄에 붙여넣으십시오. 이 두 파일은 동일한 이미지가 두 가지 역할을 수행하므로 항상 일치해야 합니다. 일치하지 않으면 한쪽 스키마로 데이터베이스를 마이그레이션하는 동안 다른 쪽 스키마로 서비스를 제공하는 상황이 발생합니다. 업그레이드는 예기치 않은 상황이 아닌 의도적인 편집이 되어야 합니다. 다이제스트를 변경하고, 백업을 수행한 뒤, docker compose pull, docker compose up -d을(를) 실행하십시오.

다른 사람이 계정을 만들기 전에 관리자 계정 생성하기

새 인스턴스에서 /admin을 열면 AFFiNE은 계정 생성 페이지로 이동시킵니다. 서버에 아직 관리자가 없기 때문입니다. 해당 과정에는 초대 코드나 설정 토큰이 없습니다. 해당 페이지를 가장 먼저 로드하는 사람이 서버의 관리자가 되므로, 등록을 마칠 때까지 포트를 닫아두어야 합니다.

이것이 위 compose 파일이 127.0.0.1에 바인딩되는 이유입니다. 본인의 컴퓨터에서 SSH 터널을 통해 접속하십시오:

ssh -L 3010:127.0.0.1:3010 you@your-server-ip

터널을 유지한 상태로 로컬 브라우저에서 http://127.0.0.1:3010/admin를 엽니다. 등록 및 로그인을 마친 후 터널을 종료하십시오. 이제야 인스턴스를 공개 도메인에 연결해도 안전합니다.

AFFiNE이 데이터를 저장하는 위치

세 개의 경로에 모든 데이터가 저장되며, 이들은 모두 사용자가 생성한 디렉터리 내부에 위치합니다.

  • ./data/postgres은 Postgres 데이터 디렉터리로, 문서, 사용자, 워크스페이스, 권한 정보가 포함됩니다.
  • ./data/storage은 컨테이너 내부의 /root/.affine/storage에 마운트되며, 업로드된 모든 파일을 보관합니다.
  • ./config/root/.affine/config에 마운트되며 config.json를 보관합니다.

Upstream은 명명된 볼륨(named volumes) 대신 바인드 마운트(bind mounts)를 사용하며, 이는 의도적인 선택입니다. Docker가 데이터를 어디에 저장했는지 확인할 필요 없이 일반적인 명령어로 해당 경로를 tar로 묶어 복사할 수 있기 때문입니다. 대신 호스트 시스템에서의 파일 소유권 관리는 사용자의 책임이 되며, 이에 대한 자세한 내용은 바인드 마운트와 명명된 볼륨에서 다룹니다.

AFFiNE 백업 방법

백업해야 할 대상은 두 가지이며, 각각 백업 방식이 다릅니다. 데이터베이스는 실시간으로 동작하는 서버이므로 실행 중에 파일을 복사하면 손상된 사본이 생성됩니다. 대신 덤프를 수행하십시오.

mkdir -p ~/affine/backup
cd ~/affine
docker compose exec -T postgres pg_dump --format c --username affine affine \
  > backup/affine-$(date +%F).dump
ls -lh backup/

덤프는 컨테이너 내부의 로컬 소켓을 통해 실행되므로 비밀번호를 묻지 않습니다. ls 출력에서 파일 크기를 확인하십시오. 수백 바이트 크기의 파일은 덤프가 실패했음에도 셸이 파일을 생성한 경우이며, 이는 6개월 뒤에야 발견하게 되는 흔한 실패 사례입니다. -T 플래그도 중요합니다. 이 플래그가 없으면 Compose가 터미널을 할당하여 바이너리 스트림을 손상시킬 수 있습니다.

업로드된 파일은 단순한 파일이므로 tar로 묶으십시오.

tar czf backup/storage-$(date +%F).tgz -C data storage
cp config/config.json backup/config-$(date +%F).json

config.json 파일은 수동으로 백업에 포함하십시오. 2026년 8월 확인 기준으로 AFFiNE 문서에는 관리자 패널을 통한 설정 내보내기 기능이 아직 구현되지 않은 것으로 명시되어 있으므로, 디스크에 있는 파일이 설정의 유일한 사본입니다. 세 가지 파일을 모두 서버 외부로 복사하십시오. 보호 대상과 동일한 디스크에 저장된 백업은 백업이 아닙니다.

복구 및 게시된 단계의 함정

복구 단계가 필요해지기 전에 공식 복구 절차를 미리 읽어두고, 꼼꼼히 확인하십시오. 2026년 8월에 게시된 문서에서는 affine.backup라는 파일을 컨테이너로 복사한 뒤 ./pg.backup에서 복구하도록 안내하는데, 이 두 파일명은 서로 다릅니다. 또한 현재 compose 파일은 데이터를 ./data/postgres에 저장하는데, 문서에서는 ./postgres 디렉터리를 삭제하라고 되어 있습니다. 코드 조각에 나온 경로를 그대로 따르지 말고, 실제로 사용 중인 경로를 따라야 합니다. 이 가이드의 레이아웃에 따른 순서는 다음과 같습니다.

cd ~/affine
docker compose down
sudo mv data/postgres data/postgres.old
docker compose up -d postgres
docker compose cp backup/affine-2026-08-08.dump postgres:/tmp/affine.dump
docker compose exec postgres pg_restore --format c --username affine \
  --dbname affine --verbose /tmp/affine.dump
docker compose up -d

rm가 아닌 mv을 사용한다는 점에 유의하십시오. 사본을 보관하지 않은 상태에서 데이터베이스를 덮어쓰며 복구하는 것은 잘못된 명령어 하나로 모든 데이터를 잃게 되는 원인이 됩니다. 기존 디렉터리를 다른 곳으로 옮겨두는 데는 비용이 들지 않습니다. tar xzf backup/storage-2026-08-08.tgz -C data를 사용하여 업로드 파일도 함께 복구하십시오. 그렇지 않으면 모든 문서에서 첨부 파일이 깨진 상태로 표시됩니다. 복구 후에는 로그인하여 이미지가 포함된 문서를 열어보십시오. 이것이 테스트입니다. 브라우저에서 열어보지 않은 복구본은 백업이 아니라 단순한 파일일 뿐입니다.

AFFiNE을 기존 프록시 뒤에 배치하기

AFFiNE은 WebSocket을 사용하며, 이는 선택 사항이 아닙니다. 문서에 명시된 바와 같이 WebSocket은 AFFiNE 동기화 및 협업 시스템의 기반이므로, 해당 연결을 업그레이드하지 않는 프록시를 사용하면 편집 내용이 동기화되지 않는 작업 공간이 생성됩니다. 페이지 로드와 로그인은 정상적으로 작동하지만, 한 브라우저에서 수행한 편집 내용이 다른 브라우저에 도달하지 않습니다. 브라우저의 개발자 도구에서 Network 탭을 열고 WS로 필터링하십시오. 연결이 반복적으로 열리고 닫힌다면 프록시가 업그레이드 요청을 전달하지 못하는 것입니다.

이미 다른 컨테이너를 위해 Traefik을 운영 중이라면, AFFiNE을 일반 서비스로 추가할 수 있습니다. ports: 블록을 affine 서비스에서 삭제한 뒤 다음을 추가하십시오.

    networks:
      - default
      - proxy
    labels:
      - 'traefik.enable=true'
      - 'traefik.docker.network=proxy'
      - 'traefik.http.routers.affine.rule=Host(`affine.example.com`)'
      - 'traefik.http.routers.affine.entrypoints=websecure'
      - 'traefik.http.routers.affine.tls.certresolver=letsencrypt'
      - 'traefik.http.services.affine.loadbalancer.server.port=3010'

그리고 파일 하단, services: 옆에 다음을 추가하십시오.

networks:
  proxy:
    external: true

인증서 리졸버(certificate resolver) 이름은 Traefik 설정에 정의된 것과 일치해야 하며, loadbalancer.server.port는 호스트 포트가 아닌 컨테이너 포트 3010이어야 합니다. Traefik은 별도의 설정 없이도 WebSocket 연결을 프록시하므로 추가할 사항은 없습니다. 스택의 나머지 부분이 이미 싱글 사인온을 위한 Authentik 뒤에 있다면, 이 라우터에 forward auth 미들웨어를 설정하여 AFFiNE에 대한 브라우저 접근을 제어할 수 있습니다. 다만 데스크톱 앱은 브라우저 세션을 공유하지 않아 동기화에 실패할 수 있으므로, 테스트가 완료될 때까지는 이 설정을 비활성화해 두십시오. 하나의 인스턴스 뒤에서 여러 앱을 운영하는 방법은 여러 앱 앞단의 단일 Traefik에서 다룹니다.

nginx에서는 업그레이드를 명시적으로 요청해야 합니다.

location / {
    proxy_pass http://127.0.0.1:3010;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    client_max_body_size 100m;
}

client_max_body_size의 nginx 기본값은 1 MB입니다. 따라서 해당 줄이 없으면 작은 사진보다 큰 모든 업로드는 413 상태 코드를 반환하며 실패합니다. 요청 자체가 도달하지 않으므로 AFFiNE 로그에는 아무것도 기록되지 않습니다. Caddy는 reverse_proxy http://127.0.0.1:3010 한 줄만 추가하면 인증서와 WebSocket 업그레이드를 자동으로 처리합니다.

셀프 호스팅 빌드에서 제외된 기능

팀을 이주시키기 전에 이 점을 스스로 솔직하게 고려해야 합니다.

실시간 협업 기능은 포함되어 있으며, 모든 규모 산정 조언이 이 기능을 중심으로 이루어집니다. AFFiNE의 자체 문서에서도 메모리 사용량의 원인을 동기화 시스템과 문서 병합으로 돌리고 있기 때문입니다. 오프라인 편집은 많은 사용자가 로컬 우선 도구를 원하는 이유이며, 데스크톱 애플리케이션은 셀프 호스팅 서버를 워크스페이스 목록에 추가하고 로그인할 수 있도록 지원합니다. 팀이 의존하는 정확한 오프라인 동작을 확정하기 전에 반드시 테스트하십시오. 네트워크를 끄고 데스크톱 앱에서 편집한 뒤, 다시 연결하여 다른 기기에서 결과를 확인해야 합니다. 기능 목록은 증거가 될 수 없으며, 이 문서도 예외는 아닙니다.

서버 측 전문 검색(full-text search)은 기본 제공되는 compose 파일에서 꺼져 있으며, 서버와 마이그레이션 작업 모두에서 AFFINE_INDEXER_ENABLED=false가 설정되어 있습니다. 이 기능을 켜려면 Manticore Search 컨테이너를 추가해야 하는데, 이는 다섯 번째 서비스가 되며 메모리 사용량도 늘어납니다. 2 GB 사양의 서버라면 이 변경이 한계치를 넘어서는 원인이 됩니다. 클라이언트 내부 검색은 현재 열려 있는 워크스페이스에서 여전히 작동합니다.

사용자를 초대하기 전에 알아두어야 할 두 가지 제한 사항이 있습니다. 셀프 호스팅 워크스페이스는 최대 10개의 좌석(seat)만 허용되며, 이를 초과하려면 AFFiNE의 Team 라이선스가 필요합니다. 셀프 호스팅 인스턴스를 위한 무제한 블롭(blob) 저장소와 무제한 블롭 크기는 문서상으로는 의도된 기능이나 2026년 8월 기준으로 아직 완전히 구현되지 않았습니다. 가정용이나 소규모 팀에게는 이 문제가 중요하지 않지만, 40명 규모의 인원을 이주시키려는 계획이라면 두 가지 모두 중요한 고려 사항입니다.

업그레이드

릴리스 노트를 먼저 읽으십시오. 특히 0.26에서 0.27로 넘어가는 것과 같은 마이너 버전 업데이트 시에는 호환성이 깨지는 변경 사항이 포함될 수 있습니다. 작업을 시작하기 전에 데이터베이스와 스토리지 디렉터리를 반드시 백업하십시오. 다음 시작 시 마이그레이션 작업이 스키마를 변경하며, 이는 되돌릴 수 없기 때문입니다. 그 후 고정된 다이제스트(pinned digest)를 변경하고, docker compose pull을 실행한 뒤 docker compose up -d를 수행하십시오. 그리고 docker compose logs -f affine_migration가 정상적으로 종료될 때까지 모니터링하십시오. docker image prune은 이후 오래된 레이어를 정리합니다. 아주 오래된 버전을 사용하는 사용자를 위한 참고 사항으로, 0.23.0 버전부터 이미지 이름이 affine-graphql에서 affine로 변경되었습니다. 따라서 그보다 이전의 compose 파일을 사용하는 경우, pull 명령으로 이미지를 찾을 수 있도록 이미지 경로를 수정해야 합니다.

FAQ

AFFiNE 컨테이너가 시작되지 않는 이유는 무엇입니까?

affine 서비스는 affine_migration 작업에 condition: service_completed_successfully을 선언합니다. 따라서 마이그레이션이 0이 아닌 상태 코드로 종료되면 서버는 시작되지 않으며 웹 인터페이스도 나타나지 않습니다. docker compose logs affine_migration를 실행하여 어느 단계에서 멈췄는지 확인하십시오. 직접 수정한 compose 파일에서 가장 흔한 원인은 pgvector/pgvector:pg16 대신 기본 postgres 이미지를 사용하는 경우입니다. AFFiNE 스키마는 pgvector 확장을 선언하고 일반 Postgres가 생성할 수 없는 vector(1024) 열을 가진 테이블을 만들기 때문입니다.

자체 호스팅 AFFiNE에는 어느 정도의 RAM이 필요합니까?

AFFiNE 요구 사항 페이지에서는 최소 4개의 CPU 코어와 2 GB의 RAM을 권장하며, 문서가 10,000 단어를 넘어가면 4 GB로 늘어납니다. 또한 10,000개의 수정 사항이 포함된 문서를 병합할 때 1 GB까지 치솟을 수 있다고 명시합니다. 2 GB 서버에서는 유휴 부하가 아니라 이러한 급증이 문제를 일으킵니다. 커널의 out-of-memory killer가 AFFiNE 프로세스를 중단시키고 restart: unless-stopped이 다시 시작하므로, 사용자는 오류 대신 페이지 새로고침을 경험하게 됩니다. docker inspect affine_server --format '{{.State.OOMKilled}}'sudo dmesg -T | grep -i 'out of memory'로 이를 확인하고, 2 GB 스왑 파일을 추가하여 급증 시 시스템이 치명적인 상태에 빠지지 않도록 하십시오.

AFFiNE은 데이터를 어디에 저장하며 무엇을 백업해야 합니까?

compose 디렉터리 하위의 세 경로에 모든 데이터가 저장됩니다. 데이터베이스용 ./data/postgres, 업로드된 파일용 ./data/storage, 그리고 config.json./config입니다. 실행 중인 Postgres는 안전하게 복사할 수 없으므로 파일을 직접 복사하는 대신 docker compose exec -T postgres pg_dump --format c --username affine affine > affine.dump을 사용하여 데이터베이스를 백업하십시오. 업로드 파일은 ./data/storage를 tar로 묶고, config.json는 수동으로 복사본을 보관하십시오. 관리자 패널에서의 설정 내보내기 기능은 2026년 8월 기준으로 아직 구현되지 않은 것으로 명시되어 있습니다.

자체 호스팅 AFFiNE에서 실시간 협업이 작동합니까?

네, 별도의 활성화 작업은 필요 없습니다. 단, 동기화가 WebSocket 연결을 통해 이루어지므로 리버스 프록시 설정이 필수입니다. Nginx를 사용하는 경우 proxy_http_version 1.1 설정과 함께 UpgradeConnection: upgrade 헤더가 필요하며, Traefik과 Caddy는 별도의 설정 없이도 해당 연결을 통과시킵니다. 프록시가 연결을 업그레이드하지 못하면 워크스페이스는 정상적으로 로드되고 로그인되지만, 한 브라우저에서 수행한 편집 내용이 다른 브라우저에 나타나지 않는 증상이 발생합니다.

기본 Postgres 이미지로 AFFiNE을 실행할 수 있습니까?

아니요. AFFiNE의 schema.prismaextensions = [pgvector(map: "vector")]을 선언하고 vector(1024) 타입의 embedding 열을 가진 4개의 테이블을 정의합니다. AI 기능이 꺼져 있어도 마이그레이션 작업은 해당 테이블을 생성합니다. 해당 확장이 컴파일된 Postgres 16 버전인 pgvector/pgvector:pg16을 사용하십시오. 외부 Postgres 서버를 사용하는 경우, 마이그레이션을 실행하기 전에 해당 서버에 pgvector를 설치하고 대상 데이터베이스에 확장을 생성해야 합니다.