Self-host AFFiNE, an open-source Notion alternative
Follow this on your own server. Start free, no card needed
AFFiNE is an open-source workspace that mixes a Notion-style document editor, an infinite whiteboard and a lightweight database into one app, with your content staying on a server you control instead of a vendor’s cloud. It’s also a genuinely non-trivial self-host: a Postgres database with the pgvector extension, Redis, and a one-shot migration job that has to finish before the app itself is allowed to start. By the end of this guide you’ll have all of that running on your own server, behind HTTPS, with backups and a repeatable upgrade.
What you’ll need
- A server with Docker and the Compose plugin. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) runs AFFiNE comfortably for a personal or small-team workspace.
- A domain or subdomain pointed at the server’s IP address.
- SSH access to the server.
- About ten minutes, most of it waiting on DNS and the first migration run.
Step 1: Point DNS at the server and open the firewall
Create an A record for your subdomain pointing at the server’s public IP, then lock the host down to just what you need:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Step 2: Generate a Postgres password
openssl rand -hex 24
mkdir -p /opt/affine
cat > /opt/affine/.env <<'EOF'
POSTGRES_PASSWORD=paste-the-generated-value-here
EOF
chmod 600 /opt/affine/.env
Step 3: Write the Compose file
This is the part most self-host guides for AFFiNE get wrong: the official Compose file runs the database migration as its own short-lived service, not as a step inside the main app’s startup, and the app container has to wait for that job to actually finish, not just start, before it boots.
services:
affine:
image: ghcr.io/toeverything/affine:0.26.7
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
db:
condition: service_healthy
affine-migration:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql://affine:${POSTGRES_PASSWORD}@db:5432/affine
REDIS_SERVER_HOST: redis
AFFINE_SERVER_EXTERNAL_URL: https://notes.yourdomain.com
AFFINE_INDEXER_ENABLED: "false"
volumes:
- affine_storage:/root/.affine/storage
- affine_config:/root/.affine/config
expose:
- "3010"
affine-migration:
image: ghcr.io/toeverything/affine:0.26.7
command: ["node", "./scripts/self-host-predeploy.js"]
environment:
DATABASE_URL: postgresql://affine:${POSTGRES_PASSWORD}@db:5432/affine
REDIS_SERVER_HOST: redis
AFFINE_INDEXER_ENABLED: "false"
volumes:
- affine_storage:/root/.affine/storage
- affine_config:/root/.affine/config
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: pgvector/pgvector:0.8.7-pg16
restart: unless-stopped
environment:
POSTGRES_USER: affine
POSTGRES_DB: affine
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- affine_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U affine -d affine"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7.2.5-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- affine_redis_data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- affine
volumes:
affine_storage:
affine_config:
affine_db_data:
affine_redis_data:
caddy_data:
caddy_config:
This guide pins 0.26.7, the current stable release as of writing, across every AFFiNE container; pgvector/pgvector:0.8.7-pg16 is Postgres 16 with the pgvector extension AFFiNE needs for its own embeddings. affine-config is its own separate volume from affine-storage on purpose: AFFiNE’s predeploy script generates a real PEM-formatted signing and encryption keypair into /root/.affine/config/private.key the first time it runs, not a plain secret you set yourself, so without a persistent volume for that directory, every redeploy would silently mint a new one and invalidate the last. AFFINE_INDEXER_ENABLED is left false here, matching AFFiNE’s own default; turn it on once you want full-text search across documents, it isn’t required to use the app.
Check the file parses cleanly:
docker compose config
Step 4: Add Caddy and start the stack
notes.yourdomain.com {
reverse_proxy affine:3010
}
docker compose up -d
docker compose up starts db and redis first, waits for both healthchecks, then runs affine-migration to completion before affine itself is allowed to start. Watch it happen:
docker compose logs -f affine-migration
You should see migration output ending in a clean exit, then:
docker compose ps
curl -I https://notes.yourdomain.com
You should see affine-migration listed as Exited (0), every other service Up or healthy, and the curl return HTTP/2 200.
Step 5: Create your workspace
Visit https://notes.yourdomain.com in a browser. Nothing is pre-seeded, so create your first account and workspace directly from the web UI. From there, invite teammates and start writing documents or whiteboards the same way you would in a hosted Notion or Miro.
Step 6: Back up Postgres and the config volume
Documents and whiteboard content live in Postgres; the signing keypair from Step 3 lives in the affine-config volume. Back up both:
cat > /opt/affine/backup.sh <<'EOF'
#!/bin/sh
cd /opt/affine
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U affine affine | gzip > backups/db-$TIMESTAMP.sql.gz
docker run --rm -v affine_affine_config:/config -v "$PWD/backups":/backup alpine \
tar czf /backup/config-$TIMESTAMP.tar.gz -C /config .
find backups -mtime +14 -delete
EOF
mkdir -p /opt/affine/backups
chmod +x /opt/affine/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/affine/backup.sh") | crontab -
Run it once by hand and check it worked: ./backup.sh && ls -lh backups. You should see both a .sql.gz and a .tar.gz file with today’s timestamp. Copy both off the server, losing the config volume without a backup means every client has to re-trust a new signing key on the next restart, not just a data loss.
Step 7: Upgrade
docker compose pull
docker compose up -d
Compose re-runs affine-migration to completion before recreating affine itself, the same dependency order as Step 4, so any schema change in the new version is applied automatically. Check docker compose logs -f affine-migration again for a clean exit before assuming the upgrade landed.
The shortcut: do it with Railyard
Everything above, Postgres with pgvector, Redis, the migration job that has to complete before the app starts, the separate config volume for the signing key, is also what Railyard’s AFFiNE stack template sets up for you. Connect a server, then pick AFFiNE in the templates catalog: app.railyard.run/deploy?stack=affine. Railyard wires the database and Redis connection strings automatically and runs the predeploy script as a release step ahead of every deploy, not just the first one, and issues the HTTPS certificate for your domain.
See deploy with a button and databases and backups for how templates and persistent volumes work on the platform more generally.
Troubleshooting
The affine container restarts in a loop and never serves anything. Check docker compose ps, if affine-migration shows anything other than Exited (0), the main app is correctly refusing to start on bad data rather than serving against an unmigrated schema; read docker compose logs affine-migration for the actual failure.
Every client gets logged out after a restart, or asks to re-trust the server. The affine-config volume was lost or recreated, which regenerates the signing key. Confirm the volume is listed in docker volume ls and wasn’t accidentally pruned; restoring the config-*.tar.gz backup from Step 6 into a fresh volume is the only way back.
HTTPS not issuing. docker compose logs caddy showing repeated ACME errors almost always means the DNS record from Step 1 hasn’t propagated or points at the wrong IP; confirm with dig +short notes.yourdomain.com.
Postgres connection refused. Confirm DATABASE_URL uses db as the hostname on both affine and affine-migration, not localhost.
Search doesn’t return results across documents. This is expected with the default setup; AFFINE_INDEXER_ENABLED is false out of the box, matching AFFiNE’s own reference Compose file, turn it on in Step 3 if you need it.
FAQ
Can I skip the config volume and just set a static key myself? No, AFFiNE’s own predeploy script generates the real PEM keypair it needs; there isn’t a documented environment variable to supply one directly, which is exactly why the volume in Step 3 is required rather than optional.
Does AFFiNE need an external email provider? Not to use the app day to day; email matters if you want invite emails or password reset flows, which AFFiNE supports configuring separately but aren’t required for a working single-workspace install.
Is real-time collaborative editing included? Yes, multiple people editing the same document or whiteboard at once is a core feature, not an add-on, backed by the same Postgres and Redis already running here.
Can I move uploaded files to object storage instead of the local volume? AFFiNE supports an S3-compatible storage backend for attachments; the default here keeps everything on affine_storage for simplicity, worth revisiting once attachments grow large.
What happens if I lose the server without a backup? Documents, whiteboards and the signing key are all gone; there’s no recovery path into data that was never backed up elsewhere, which is why Step 6 isn’t optional in practice.
A working AFFiNE install is four services that depend on each other in a specific order: Postgres and Redis first, the migration job next, the app last. Once that order is right and backups are running, there’s very little left to maintain beyond the occasional version bump from Step 7.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.