SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

How to Self-host AFFiNE with Docker Compose

Run AFFiNE for one VPS with Docker Compose: see the 4 containers, pinned image tags, data location, backups, and wetin 2 GB RAM really fit handle.

Wetin you go get when you self-host AFFiNE

Self-hosting AFFiNE go give you Notion-style workspace for server wey you control. E go run as four containers: the application, one-shot migration job, Postgres, and Redis. Real-time collaboration dey included, reach the 10 seats wey self-hosted workspace dey get by default. Installation na one compose file plus one JSON config file. The things wey need careful planning na image tags, disk layout, memory ceiling, and the proxy wey you put for front.

AFFiNE dey keep document editor and infinite canvas for the same workspace. So, you fit read one page as document or spread am out as whiteboard. If you never decide wetin to run, read the comparison of self-hosted Notion alternatives first. This guide assume say you don make the choice. E cover how to run AFFiNE properly instead of comparing am again.

We check everything here against AFFiNE self-host documentation and the published release files on 8 August 2026. The newest stable release for that date na 0.27.3, wey dem publish on 23 July 2026.

Wetin the four containers actually dey do

affine na the server and web client inside one image. E dey listen on port 3010.

affine_migration na one-time job wey dey run node ./scripts/self-host-predeploy.js, apply the database migrations, then exit. The application declare condition: service_completed_successfully on that job, so if migration exit with non-zero status, affine no go start at all. When web interface no come up, na that job log you suppose read first.

postgres dey keep your documents, users, workspaces and permissions. The image wey dem ship na pgvector/pgvector:pg16, wey be ordinary Postgres 16 with pgvector extension compiled inside. pgvector add vector column type to Postgres. Na the numeric format dem dey use store embeddings, so text fit dey searched by meaning.

redis na hard dependency: both server and migration job go wait for its health check before dem start. Notice say the compose file wey dem ship no give Redis volume. Nothing inside am survive a docker compose down, and this tell you clearly say e no hold any content wey belong to you and e no need backup.

Why Postgres image na pgvector, no be stock postgres

Requirement dey come from AFFiNE schema, no be personal preference. For schema.prisma, datasource declare extensions = [pgvector(map: "vector")], and four tables get embedding column wey type na vector(1024). Migration job go create these tables whether you ever turn on AI features or not, so extension must dey database before migration fit finish. If you replace am with postgres:16, extension go disappear, migration no fit create those columns, and server go just dey wait for job wey don fail.

AFFiNE move go pgvector image for version 0.21. If your install older pass that, to edit image line alone no complete the upgrade, so read upgrade page for AFFiNE self-host docs before you pull anything.

One more thing about that tag. pg16 mean Postgres 16, and Postgres major version no be number wey you fit just bump. If you change am to pg17 for existing data directory, Postgres go refuse to start, with line like The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17 inside docker compose logs postgres. Major version move mean dump the data, then restore am into fresh data directory.

How much CPU and RAM self-hosted AFFiNE need

AFFiNE requirements page dey ask for at least 4 CPU cores and 2 GB RAM. E increase memory requirement to 4 GB once your documents pass 10,000 words. The same page explain where the memory dey go: sync system and document merging. E give one figure wey worth remembering: merging document with 10,000 modifications fit reach 1 GB at peak.

Now compare this with 2 GB plan where two people dey write. Average usage dey okay. Postgres and Node process dey below the limit, with some space remaining. Na the peak be the problem. One big merge fit request 1 GB on top of everything wey already dey resident. For 2 GB server wey no get swap, kernel's out-of-memory (OOM) killer go answer by killing the biggest process. Na AFFiNE server go be that process.

Your colleague no go see error. The page go just reload because restart: unless-stopped bring the container back within seconds. No guess this one; confirm am:

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

true from the first command, or a Killed process line wey name node from the second command, mean say memory don finish. E no mean say you find bug. Fix the problem from both sides. Add swap first, so memory spike go make things slow instead of causing failure:

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 suppose now report 2.0Gi total swap. Swap no make AFFiNE fast, and na not wetin e suppose do. E change one-second spike to one slow second instead of dead container. The other side of the fix na to stop Postgres from growing its cache into the space wey application need during merge. Na memory limits for a Compose service dey handle this.

Storage dey much easier to predict. These na the figures wey AFFiNE publish for that same page:

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
  }
]

The server install take 1.5 GB. One thousand documents of about one thousand words each add 0.1 GB of Postgres data, which almost be nothing. One thousand uploaded files add 10 GB, and na this carry the main storage load. These figures na planning estimates wey dem publish, not measurements from running instance. So treat dem as a general pattern, not promise. Na the pattern matter: your database go remain small, while your uploads go determine how much disk you need.

Write the compose file yourself, with the tags pinned

The documented install dey download one ready-made file with curl -L -o docker-compose.yml https://github.com/toeverything/AFFiNE/releases/latest/download/docker-compose.yml. E dey work. But make you know one detail before you rely on am: as of 8 August 2026, the file wey attach to release 0.27.3 still dey read its paths from one .env file, using ${UPLOAD_LOCATION}, ${CONFIG_LOCATION} and ${DB_DATA_LOCATION}. But the documentation reference page show newer layout wey keep everything under ./data and no need .env at all. Both ones genuine. If you write the file yourself, the matter clear, and you still need edit am to pin the images and set database password.

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

Compose dey read .env from the project directory by itself and substitute ${DB_PASSWORD} for you. So the password no dey show for the file wey you fit paste inside support thread. Make you keep this habit for every stack wey you run, and the reason dey for keeping secrets out of the compose file.

Now write ~/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

Four things different from the file upstream dey ship, and each one get reason.

  • 127.0.0.1:3010:3010 publish the port for loopback address only. So nothing outside the server fit reach AFFiNE until you decide how. The upstream '3010:3010' bind every interface, and for most VPS images, that one include the public interface.
  • POSTGRES_HOST_AUTH_METHOD: trust don comot, and password dey set instead. Trust authentication dey accept any connection to that database as the affine user without password. E limited to the private Compose network, and that one dey okay until the day you attach another container to the network or publish 5432 while you dey debug.
  • redis:8-alpine replace bare redis, wey resolve to latest. As of August 2026, that one na Redis 8. So the pin keep the major version wey you test and stop future Redis 9 from arriving during unrelated docker compose pull.
  • pgvector/pgvector:pg16 remain exactly as upstream set am, because of the reason wey we give above.

POSTGRES_PASSWORD dey read only when Postgres create its data directory for the first time. If the instance don already exist, set the password with docker compose exec postgres psql -U affine -c "ALTER USER affine WITH PASSWORD 'yourpassword'", then update DATABASE_URL to match.

Configuration dey for config/config.json

AFFiNE dey read im settings from config/config.json, wey be the directory you mount for /root/.affine/config. Nothing dey create that file for you, so write am before the first start. Open ~/affine/config/config.json with editor and put this content inside, using your own domain instead of the example:

{
  "$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 must be the address wey your users actually open for browser. AFFiNE dey build share links and workspace invitations from that value, so if you leave am as http://localhost:3010, invitation wey you send go point the recipient to their own machine and e go fail there. Set am to the public HTTPS address before the first start, so the file and the admin panel no go disagree about am.

copilot controls the AI features. copilot.byok.enabled na the bring-your-own-key switch, wey dey allow workspace owner paste their own model provider key for workspace settings. Self-hosting AFFiNE no include AI subscription. Leave both false if you no want am.

Start the stack:

docker compose up -d
docker compose ps

docker compose ps suppose list affine_postgres and affine_redis as healthy, affine_server as running, and affine_migration_job with the state exited (0). Any other exit code for the migration job na the thing to investigate, and im log go name the step wey stop:

docker compose logs affine_migration

Pin the image before you forget

stable na moving tag. AFFiNE release workflow dey point several tags to each stable build, and two of dem matter here: stable, wey dem dey point again for every release, and stable- followed by the git short hash, wey no dey change. If you leave am for stable, a docker compose pull six months from now go fetch different image and run its migrations against your database when you no choose. Pin the exact image wey you test:

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

That one go print line like ghcr.io/toeverything/affine@sha256: followed by long hash. Paste the complete string inside the image: line for both affine and affine_migration. The two ones must always match, because na the same image wey dey play two roles. If dem no match, you fit migrate the database to one schema while you dey serve am with another. Upgrade go then be deliberate edit instead of surprise: change the digest, back up, docker compose pull, docker compose up -d.

Make admin account before anybody else

Open /admin for fresh instance, and AFFiNE go send you go account creation page because server never get administrator yet. That flow no get invitation code or setup token. The first person wey load that page go become administrator for your server, so port must remain closed until you don register.

Na why compose file above dey bind to 127.0.0.1. Reach am through SSH tunnel from your own machine:

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

Leave am running and open http://127.0.0.1:3010/admin for your local browser. Register and log in, then close the tunnel. Na only after this e safe to put the instance for public name.

Wey AFFiNE dey keep your data

Three paths hold everything, and all of dem dey inside the directory wey you create.

  • ./data/postgres na the Postgres data directory: documents, users, workspaces, permissions.
  • ./data/storage dey mount for /root/.affine/storage inside the container and e hold every uploaded file.
  • ./config dey mount for /root/.affine/config and e hold config.json.

Upstream dey use bind mounts here instead of named volumes, and dem choose am deliberately: you fit tar and copy these paths with ordinary commands, without asking Docker where e put dem. The cost be say file ownership for the host don become your responsibility. Na this tradeoff bind mounts and named volumes dey explain.

How to back up AFFiNE

Two things need backup, and the way you back up dem no be the same. The database na live server, so if you copy the files while e dey run, the copy fit corrupt. Dump am instead:

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/

The dump dey run inside the container through its local socket, so e no go ask for password. Check the size for that ls output. If the file na just few hundred bytes, e mean say the dump fail while the shell still create the file. Na this kind failure people dey discover six months later. The -T matter too: without am, Compose fit allocate terminal and corrupt the binary stream.

Uploaded files na just files, so use tar for dem:

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

Keep config.json inside your backup by hand. AFFiNE documentation still say configuration export from the admin panel never implement, as checked for August 2026. So the file for disk na the only copy of your settings. Copy all three files comot from the server. Backup wey dey for the same disk with the thing wey e suppose protect no be backup.

Restore, and one trap for the published steps

Read the official restore steps before you need dem, and read dem well. As dem publish am for August 2026, dem copy file wey dem name affine.backup enter the container, then restore from ./pg.backup. These na two different names. Dem also remove ./postgres directory, but the current compose file dey keep its data for ./data/postgres. Follow the paths wey you actually use, no be the ones for the snippet. This na the sequence for the layout wey this guide use:

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

Notice the mv instead of rm. If you restore over database wey you no keep copy of, one wrong command fit turn to complete data loss. Moving the old directory go one side no cost anything. Restore the uploads too with tar xzf backup/storage-2026-08-08.tgz -C data, or every document go show broken attachments. Then log in and open document wey get image. Na this be the test. Restore wey you never open for browser na just file, e no be backup.

Putting AFFiNE behind proxy wey you already dey run

AFFiNE dey use WebSocket, and you no fit skip am. The documentation talk am clearly: WebSocket na the foundation of AFFiNE sync and collaboration system. So, if proxy no upgrade those connections, workspace go load but editing go quietly stop syncing. Page go open, login go work, but edit wey you make for one browser no go reach the other one. For your browser developer tools, open Network tab and filter to WS. If connection dey open and close repeatedly, proxy no dey pass the upgrade.

If you already dey run Traefik for other containers, AFFiNE go join am as normal service. Delete the ports: block from the affine service, then add:

    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'

For bottom of the file, together with services:, add:

networks:
  proxy:
    external: true

The certificate resolver name must match the one wey you define for your Traefik configuration. loadbalancer.server.port na the container port 3010, never host port. Traefik dey proxy WebSocket connections without extra configuration, so you no need add anything else. If the rest of your stack already dey behind Authentik for single sign-on, forward auth middleware for this router go control browser access to AFFiNE. But leave am off until you test the desktop app, because e no get browser session and e go simply fail to sync. One Traefik wey dey in front of several apps cover how to run several apps behind one instance.

For nginx, you must request the upgrade explicitly:

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 for nginx dey default to 1 MB. Without that line, every upload wey pass small photo size go fail with 413 status. Nothing go appear for AFFiNE logs because the request never reach am. Caddy need one line, reverse_proxy http://127.0.0.1:3010, and e go handle certificates and WebSocket upgrades by itself.

Wetin the self-hosted build no include

Be honest with yourself about this before you move team come use am.

Real-time collaboration dey available, and na the feature wey all the sizing advice dey talk about, because AFFiNE own documentation talk say memory use come from the sync system and document merging. Offline editing na why plenty people want local-first tool, and the desktop application fit add your self-hosted server to its workspace list and log in against am. Test the exact offline behaviour wey your team depend on before you commit: edit for the desktop app when network off, reconnect, then check the result for another device. Feature lists no be evidence, and this one no different.

Server-side full-text search dey off for the compose file wey dem ship, where AFFINE_INDEXER_ENABLED=false dey set for the server and migration job. To turn am on, you need add Manticore Search container. This one go be the fifth service and e go use more memory. For a 2 GB box, na this change dey push you over the edge. Search inside the client still dey work for the workspace wey you open.

Two limits dey worth knowing before you invite people. A self-hosted workspace fit get at most 10 seats, and if you pass that number, you need Team license from AFFiNE. Documentation describe unlimited blob storage and unlimited blob size for self-hosted instances as things wey dem intend to support but never fully implement, as checked in August 2026. None of these matter for household or small team. Both matter if you plan move forty people.

Upgrades

Read the release notes first, especially for minor version bump like 0.26 to 0.27, where breaking changes fit land. Back up the database and storage directory before you touch anything, because migration job go alter your schema for the next start and you no get way to undo am. Then change the pinned digest, run docker compose pull followed by docker compose up -d, and monitor docker compose logs -f affine_migration until e exit cleanly. docker image prune go clear the old layers afterwards. One historical note for anybody wey dey use very old install: from version 0.23.0, image name change from affine-graphql to affine, so compose file wey older than that need you rewrite the image lines before pull fit find anything.

FAQ

Why AFFiNE container no dey start?

The affine service declare condition: service_completed_successfully for the affine_migration job. So, if migration stop with any status wey no be 0, server no go start and no web interface go show at all. Run docker compose logs affine_migration to see which step stop. The commonest cause for compose file wey person edit by hand na stock postgres image wey replace pgvector/pgvector:pg16. This one happen because AFFiNE schema declare the pgvector extension and create tables with vector(1024) columns wey plain Postgres no fit create.

How much RAM self-hosted AFFiNE need?

AFFiNE requirements page ask for at least 4 CPU cores and 2 GB of RAM. E fit rise to 4 GB when documents pass 10,000 words. E also note say merging document with 10,000 modifications fit reach 1 GB at peak. For 2 GB server, na this peak dey cause problem, no be the idle load. The kernel out-of-memory killer stop AFFiNE process, then restart: unless-stopped start am again. So users go see page reload instead of error. Confirm am with docker inspect affine_server --format '{{.State.OOMKilled}}' and sudo dmesg -T | grep -i 'out of memory'. Then add 2 GB swap file so spike go slow instead of causing failure.

Where AFFiNE dey store my data, and wetin I suppose back up?

Three paths under your compose directory hold everything: ./data/postgres for database, ./data/storage for uploaded files, and ./config for config.json. Back up database with docker compose exec -T postgres pg_dump --format c --username affine affine > affine.dump instead of copying the files, because you no fit safely copy running Postgres. Tar ./data/storage for the uploads. Keep one copy of config.json by hand too, because admin panel list configuration export as not yet implemented as of August 2026.

Real-time collaboration dey work for self-hosted AFFiNE?

Yes, and you no need enable anything for am. The main requirement na your reverse proxy, because sync dey use WebSocket connections. For nginx, this mean proxy_http_version 1.1 plus the Upgrade and Connection: upgrade headers. Traefik and Caddy pass those connections through without extra configuration. If proxy no upgrade the connections, workspace go load and login normally, but edits wey you make for one browser no go appear for another.

I fit run AFFiNE with stock Postgres image?

No. AFFiNE schema.prisma declare extensions = [pgvector(map: "vector")] and define four tables with an embedding column of type vector(1024). The migration job create those tables even when AI features dey switched off. Use pgvector/pgvector:pg16, wey be Postgres 16 with that extension compiled inside. If you point AFFiNE to external Postgres server instead, install pgvector for there and create the extension inside the target database before you run the migration.