SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host AFFiNE: a Notion-style workspace

Run AFFiNE on one VPS with Docker Compose: the four containers, pinned image tags, where your data lives, backups, and what 2 GB of RAM really buys.

What you get when you self-host AFFiNE

Self-hosting AFFiNE gives you a Notion-style workspace on a server you control, running as four containers: the application, a one-shot migration job, Postgres, and Redis. Real-time collaboration is included, up to the 10 seats a self-hosted workspace gets by default. The install is one compose file plus one JSON config file. What needs thought is the image tags, the disk layout, the memory ceiling, and the proxy you put in front.

AFFiNE keeps a document editor and an infinite canvas in the same workspace, so one page can be read as a document or spread out as a whiteboard. If you are still deciding what to run, read the comparison of self-hosted Notion alternatives first. This guide assumes the choice is made, and covers running AFFiNE properly rather than comparing it again.

Everything here was checked against the AFFiNE self-host documentation and the published release files on 8 August 2026. The newest stable release on that date was 0.27.3, published on 23 July 2026.

What the four containers actually do

affine is the server and the web client in one image. It listens on port 3010.

affine_migration is a one-shot job that runs node ./scripts/self-host-predeploy.js, applies the database migrations, and exits. The application declares condition: service_completed_successfully on that job, so a migration that exits with a non-zero status means affine never starts at all. When the web interface does not come up, that job's log is the first thing to read.

postgres holds your documents, users, workspaces and permissions. The shipped image is pgvector/pgvector:pg16, which is ordinary Postgres 16 with the pgvector extension compiled in. pgvector adds a vector column type to Postgres, the numeric form used to store embeddings so that text can be searched by meaning.

redis is a hard dependency: both the server and the migration job wait for its health check before they start. Notice what the shipped compose file does not give Redis, which is a volume. Nothing inside it survives a docker compose down, and that tells you plainly that it holds no content of yours and needs no backup.

Why the Postgres image is pgvector and not stock postgres

The requirement comes from AFFiNE's schema, not from a preference. In schema.prisma the datasource declares extensions = [pgvector(map: "vector")], and four tables carry an embedding column typed vector(1024). The migration job creates those tables whether or not you ever turn the AI features on, so the extension must already exist in the database before the migration can finish. Swap in postgres:16 and the extension is gone, the migration cannot create those columns, and the server sits there waiting for a job that failed.

AFFiNE moved to the pgvector image at version 0.21. On an install older than that, editing the image line is not the whole upgrade, so read the upgrade page in the AFFiNE self-host docs before you pull anything.

One more thing about that tag. pg16 means Postgres 16, and a Postgres major version is not a number you can bump. Change it to pg17 over an existing data directory and Postgres refuses to start, with a line like The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17 in docker compose logs postgres. A major version move means a dump and a restore into a fresh data directory.

How much CPU and RAM does self-hosted AFFiNE need

AFFiNE's requirements page asks for at least 4 CPU cores and 2 GB of RAM, and raises memory to 4 GB once your documents run past 10,000 words. The same page says where the memory goes: the sync system and document merging. It gives one figure worth memorising, which is that merging a document with 10,000 modifications can peak at 1 GB.

Now read that against a 2 GB plan with two people writing. The average is fine. Postgres and the Node process sit under the limit with room left. The peak is the problem. A single large merge can ask for 1 GB on top of everything already resident, and on a 2 GB box with no swap the kernel's out-of-memory (OOM) killer answers that request by killing the biggest process, which is the AFFiNE server.

Your colleague does not see an error. They see the page reload, because restart: unless-stopped brings the container back within seconds. Do not guess at this, confirm it:

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 naming node from the second, means you ran out of memory rather than found a bug. Fix it from both ends. Add swap first, so a spike becomes slow instead of fatal:

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 should now report a 2.0Gi swap total. Swap does not make AFFiNE fast, and it is not meant to. It turns a one-second spike into a slow second instead of a dead container. The other end of the fix is to stop Postgres growing its cache into the space the application needs at merge time, which is what memory limits on a Compose service are for.

Storage is far easier to predict. These are the figures AFFiNE publishes on 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 takes 1.5 GB. A thousand documents of roughly a thousand words each add 0.1 GB of Postgres data, which is close to nothing. A thousand uploaded files add 10 GB, which is the whole story. These are published planning figures rather than measurements from a running instance, so treat them as a shape and not a promise. The shape is what matters: your database stays small, and your uploads decide your disk.

Write the compose file yourself, with the tags pinned

The documented install downloads a ready-made file with curl -L -o docker-compose.yml https://github.com/toeverything/AFFiNE/releases/latest/download/docker-compose.yml. That works. One detail is worth knowing before you rely on it: as of 8 August 2026 the file attached to release 0.27.3 still reads its paths from a .env file, using ${UPLOAD_LOCATION}, ${CONFIG_LOCATION} and ${DB_DATA_LOCATION}, while the documentation's reference page shows a newer layout that keeps everything under ./data and needs no .env at all. Both are genuine. Writing the file yourself settles the question, and you have to edit it anyway to pin the images and set a database password.

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

Compose reads .env from the project directory on its own and substitutes ${DB_PASSWORD} for you, so the password never appears in the file you would paste into a support thread. That habit is worth keeping across every stack you run, and the reasoning is in 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 differences from the file upstream ships, and each one has a reason.

  • 127.0.0.1:3010:3010 publishes the port on the loopback address only, so nothing outside the server can reach AFFiNE until you decide how. The upstream '3010:3010' binds every interface, and on most VPS images that includes the public one.
  • POSTGRES_HOST_AUTH_METHOD: trust is gone and a password is set instead. Trust authentication accepts any connection to that database as the affine user with no password. It is limited to the private Compose network, which is fine until the day you attach one more container to that network or publish 5432 while debugging.
  • redis:8-alpine replaces a bare redis, which resolves to latest. As of August 2026 that is Redis 8, so the pin keeps the major version you tested and stops a future Redis 9 arriving during an unrelated docker compose pull.
  • pgvector/pgvector:pg16 stays exactly as upstream sets it, for the reason given above.

POSTGRES_PASSWORD is only read when Postgres creates its data directory for the first time. On an instance that already exists, set the password with docker compose exec postgres psql -U affine -c "ALTER USER affine WITH PASSWORD 'yourpassword'" and then update DATABASE_URL to match.

Configuration lives in config/config.json

AFFiNE reads its settings from config/config.json, which is the directory you mounted at /root/.affine/config. Nothing creates that file for you, so write it before the first start. Open ~/affine/config/config.json in an editor and give it this content, with your own domain in place 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 your users actually open in a browser. AFFiNE builds share links and workspace invitations from that value, so if it is left at http://localhost:3010, an invitation you send points the recipient at their own machine and fails there. Set it to the public HTTPS address before the first start, so the file and the admin panel never disagree about it.

copilot controls the AI features. copilot.byok.enabled is the bring-your-own-key switch, which lets a workspace owner paste their own model provider key into workspace settings. Self-hosting AFFiNE does not include an AI subscription. Leave both false if you do not want it.

Start the stack:

docker compose up -d
docker compose ps

docker compose ps should 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 on the migration job is the thing to chase, and its log names the step that stopped:

docker compose logs affine_migration

Pin the image before you forget

stable is a moving tag. AFFiNE's release workflow points several tags at each stable build, and two of them matter here: stable, which is repointed at every release, and stable- followed by the git short hash, which is not. Left on stable, a docker compose pull six months from now fetches a different image and runs its migrations against your database at a moment you did not choose. Pin the exact image you tested:

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

That prints a line like ghcr.io/toeverything/affine@sha256: followed by a long hash. Paste the whole string into the image: line of both affine and affine_migration. Those two must always match, because they are the same image playing two roles, and a mismatch means migrating the database to one schema while serving it with another. Upgrading is then a deliberate edit rather than a surprise: change the digest, back up, docker compose pull, docker compose up -d.

Create the admin account before anyone else does

Open /admin on a fresh instance and AFFiNE sends you to an account creation page, because the server has no administrator yet. There is no invitation code and no setup token in that flow. The first person to load that page becomes the administrator of your server, so the port must stay closed until you have registered.

That is why the compose file above binds to 127.0.0.1. Reach it through an SSH tunnel from your own machine:

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

Leave that running and open http://127.0.0.1:3010/admin in your local browser. Register and log in, then close the tunnel. Only now is it safe to put the instance on a public name.

Where AFFiNE keeps your data

Three paths hold everything, and they all sit inside the directory you created.

  • ./data/postgres is the Postgres data directory: documents, users, workspaces, permissions.
  • ./data/storage is mounted at /root/.affine/storage in the container and holds every uploaded file.
  • ./config is mounted at /root/.affine/config and holds config.json.

Upstream uses bind mounts here rather than named volumes, and that choice is deliberate: you can tar and copy these paths with ordinary commands, without asking Docker where it put them. The cost is that file ownership on the host is now your problem, which is the trade covered in bind mounts and named volumes.

How to back up AFFiNE

Two things need backing up, and they are backed up differently. The database is a live server, so copying its files while it runs gives you a corrupt copy. Dump it 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 runs inside the container over its local socket, so it does not prompt for the password. Check the size in that ls output. A file of a few hundred bytes means the dump failed while the shell created the file anyway, which is the failure people discover six months later. The -T matters too: without it Compose can allocate a terminal and corrupt the binary stream.

Uploaded files are just files, so tar them:

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

Keep config.json in your backup by hand. The AFFiNE documentation still lists configuration export from the admin panel as not implemented, checked in August 2026, so the file on disk is the only copy of your settings. Copy all three files off the server. A backup on the same disk as the thing it protects is not a backup.

Restoring, and one trap in the published steps

Read the official restore steps before you need them, and read them closely. As published in August 2026 they copy a file named affine.backup into the container and then restore from ./pg.backup, which are two different names, and they remove a ./postgres directory while the current compose file keeps its data in ./data/postgres. Follow the paths you actually used instead of the ones in the snippet. Here is the sequence against the layout in this guide:

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

Note the mv rather than an rm. Restoring over a database you have not kept a copy of is how one bad command becomes total data loss, and moving the old directory aside costs nothing. Restore the uploads as well, with tar xzf backup/storage-2026-08-08.tgz -C data, or every document renders with broken attachments. Then log in and open a document that contains an image. That is the test. A restore you have not opened in a browser is a file, not a backup.

Putting AFFiNE behind a proxy you already run

AFFiNE speaks WebSocket, and it is not optional. The documentation is blunt about it: WebSocket is the basis of the AFFiNE sync and collaboration system, so a proxy that does not upgrade those connections gives you a workspace where editing quietly stops syncing. The page loads, login works, and an edit made in one browser never reaches the other. In your browser's developer tools, open the Network tab and filter to WS. A connection that opens and closes over and over is a proxy that is not passing the upgrade.

If you already run Traefik for other containers, AFFiNE joins it as a 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'

and at the bottom of the file, alongside services::

networks:
  proxy:
    external: true

The certificate resolver name has to match the one defined in your Traefik configuration, and loadbalancer.server.port is the container port 3010, never a host port. Traefik proxies WebSocket connections with no extra configuration, so there is nothing further to add. Running several apps behind one instance of it is covered in a single Traefik in front of several apps.

On nginx you have to ask for 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 defaults to 1 MB in nginx, so without that line every upload larger than a small photo fails with a 413 status and nothing appears in the AFFiNE logs, because the request never arrived. Caddy needs one line, reverse_proxy http://127.0.0.1:3010, and handles certificates and WebSocket upgrades itself.

What the self-hosted build leaves out

Be honest with yourself about this before you move a team over.

Real-time collaboration is present, and it is the feature all of the sizing advice is about, since AFFiNE's own documentation attributes memory use to the sync system and to document merging. Offline editing is the reason many people want a local-first tool, and the desktop application can add your self-hosted server to its workspace list and log in against it. Test the exact offline behaviour your team depends on before you commit: edit in the desktop app with the network off, reconnect, then check the result on a second device. Feature lists are not evidence, and that includes this one.

Server-side full-text search is off in the shipped compose file, where AFFINE_INDEXER_ENABLED=false is set on the server and on the migration job. Turning it on means adding a Manticore Search container, which is a fifth service and more memory. On a 2 GB box, that is the change that pushes you over the edge. Search inside the client still works on the workspace you have open.

Two limits are worth knowing before you invite people. A self-hosted workspace is granted at most 10 seats, and going past that needs a Team license from AFFiNE. Unlimited blob storage and unlimited blob size for self-hosted instances are described in the documentation as intended but not yet fully implemented, checked in August 2026. Neither of these matters for a household or a small team. Both matter if you were planning to move forty people.

Upgrades

Read the release notes first, especially for a minor version bump such as 0.26 to 0.27, where breaking changes land. Back up the database and the storage directory before you touch anything, because the migration job alters your schema on the next start and there is no undo. Then change the pinned digest, run docker compose pull followed by docker compose up -d, and watch docker compose logs -f affine_migration until it exits cleanly. docker image prune clears the old layers afterwards. One historical note for anyone on a very old install: from version 0.23.0 the image name changed from affine-graphql to affine, so a compose file older than that needs its image lines rewritten before a pull will find anything.

FAQ

Why does the AFFiNE container never start?

The affine service declares condition: service_completed_successfully on the affine_migration job, so if the migration exits with any status other than 0, the server is never started and no web interface appears at all. Run docker compose logs affine_migration to see which step stopped. The most common cause on a hand-edited compose file is a stock postgres image in place of pgvector/pgvector:pg16, because the AFFiNE schema declares the pgvector extension and creates tables with vector(1024) columns that plain Postgres cannot make.

How much RAM does self-hosted AFFiNE need?

AFFiNE's requirements page asks for at least 4 CPU cores and 2 GB of RAM, rising to 4 GB when documents pass 10,000 words, and it notes that merging a document with 10,000 modifications can peak at 1 GB. On a 2 GB server that peak is what kills you, not the idle load: the kernel out-of-memory killer stops the AFFiNE process, and restart: unless-stopped starts it again, so users see a page reload rather than an error. Confirm it with docker inspect affine_server --format '{{.State.OOMKilled}}' and sudo dmesg -T | grep -i 'out of memory', then add a 2 GB swap file so a spike is slow instead of fatal.

Where does AFFiNE store my data, and what do I back up?

Three paths under your compose directory hold everything: ./data/postgres for the database, ./data/storage for uploaded files, and ./config for config.json. Back up the database with docker compose exec -T postgres pg_dump --format c --username affine affine > affine.dump rather than by copying the files, because a running Postgres cannot be copied safely. Tar ./data/storage for the uploads and keep a copy of config.json by hand, since export of the configuration from the admin panel is listed as not yet implemented as of August 2026.

Does real-time collaboration work on a self-hosted AFFiNE?

Yes, and nothing needs enabling for it. The one requirement is your reverse proxy, because sync runs over WebSocket connections. On nginx that means proxy_http_version 1.1 plus the Upgrade and Connection: upgrade headers, while Traefik and Caddy pass those connections through with no extra configuration. The symptom of a proxy that does not upgrade them is a workspace that loads and logs in normally while edits made in one browser never appear in another.

Can I run AFFiNE with a stock Postgres image?

No. AFFiNE's schema.prisma declares extensions = [pgvector(map: "vector")] and defines four tables with an embedding column of type vector(1024), and the migration job creates those tables even when the AI features are switched off. Use pgvector/pgvector:pg16, which is Postgres 16 with that extension compiled in. If you point AFFiNE at an external Postgres server instead, install pgvector on it and create the extension in the target database before running the migration.