Skip to content

How to Deploy AFFiNE on a VPS

Updated Oct 2026

verified on Ubuntu 26.04 · Oct 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host AFFiNE — the local-first workspace that combines docs, whiteboards and databases — on a VPS with PostgreSQL and Redis in Docker Compose behind Caddy HTTPS, with backups you can restore and a pinned-image upgrade path.

Before you start
  • A VPS with 2 GB+ RAM (AFFiNE's own planning figure; PostgreSQL, Redis and the Node.js server all share the box)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain or subdomain you can point at the server
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What AFFiNE is

AFFiNE is a workspace that puts documents, whiteboards and databases on one canvas. The editor is local-first, so your notes live on your device and sync to a server, and the server adds real-time collaboration, sharing and a web version. It is written in TypeScript on Node.js with a Rust core, uses PostgreSQL and Redis, and is rated 2 / 5 to deploy. People look at it as a self-hosted alternative to Notion; see Notion alternatives for how it compares with the other options. If you only want plain, structured notes, Outline is a different shape of tool.

A word on licensing, because "open source" is not the full story here. AFFiNE is mixed-licensed: the client is MIT, while the server is under AFFiNE's own EE License (see packages/backend/server/LICENSE upstream). That means it is source-available and self-hostable for free, but it is not plain MIT software. Read the license files in the repository before you build a business or a product on top of it.

This guide uses the official Docker images and goes from "I have a server" to AFFiNE on HTTPS with the first administrator created and a backup you have restored. For help choosing a provider and tier, see Best VPS for self-hosting.

Server sizing

AFFiNE's project plans for at least 2 GB of RAM, and that is the floor this guide assumes. On the catalog's test box the server container idled at roughly 213 MB of RAM and the whole install used about 1.5 GB of disk. In our own verification run the AFFiNE, PostgreSQL and Redis containers together idled at roughly 250 to 300 MB: the AFFiNE server between about 190 and 240 MB, PostgreSQL around 50 MB and Redis a few megabytes. Idle figures flatter any collaborative editor. Memory climbs with every open workspace, every live editing session and every large document or whiteboard, and the migration job at startup briefly adds to it, so 2 GB gives you headroom rather than waste.

Disk grows with your database and with the blob storage where uploaded images and attachments live, so size it for the files your team will actually add.

Prepare the server

Update the system and install the few tools this guide uses:

sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils

If you're logged in as root, create a non-root sudo user and continue as that user:

adduser deploy
usermod -aG sudo deploy

Then open only SSH and the two ports the reverse proxy needs:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

AFFiNE will be reachable only through the reverse proxy. That matters on a Docker host: a published container port goes around ufw entirely, so the Compose file below binds the app to 127.0.0.1, and Caddy becomes the only public door. PostgreSQL and Redis publish nothing at all. (The official Compose file publishes port 3010 on every interface and lets PostgreSQL accept any connection without a password, which is acceptable inside a private network and a poor default on a public VPS.)

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Docker

If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):

command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version

The group change applies on your next login; until then the commands below use sudo docker.

Install AFFiNE

Create a project directory and generate the secrets. Set your hostname first (this guide uses notes.example.com; replace it with yours) and choose the email address for the first administrator. The guard keeps the step safe to re-run, because regenerating the database password on an existing install would lock AFFiNE out of its own database:

mkdir -p ~/affine/config && cd ~/affine
DOMAIN=notes.example.com
ADMIN_EMAIL=admin@example.com
if [ ! -f .env ]; then
  ( umask 077
    {
      echo "DOMAIN=$DOMAIN"
      echo "ADMIN_EMAIL=$ADMIN_EMAIL"
      echo "DB_PASSWORD=$(openssl rand -hex 16)"
      echo "ADMIN_PASSWORD=$(openssl rand -hex 12)"
    } > .env )
fi
grep -c -E '^(DOMAIN|ADMIN_EMAIL|DB_PASSWORD|ADMIN_PASSWORD)=' .env

AFFiNE reads a small JSON config file. Download the example from the same release as the image you are about to run, and point externalUrl at your HTTPS address, because AFFiNE uses it to build the links it hands out (invites, share links, email links). Retries are on because GitHub release downloads occasionally fail:

cd ~/affine
. ./.env
[ -s config/config.json ] || curl -fsSL --retry 5 --retry-delay 3 -o config/config.json \
  https://github.com/toeverything/AFFiNE/releases/download/v0.27.4/config.json.example
sed -i "s|\"externalUrl\": \".*\"|\"externalUrl\": \"https://$DOMAIN\"|" config/config.json
grep externalUrl config/config.json

Now the Compose file. It is based on AFFiNE's official one, with four changes: the image tag is pinned instead of floating on stable, the app binds to loopback, PostgreSQL gets a real password, and Caddy is added in front. The migration job runs the database migrations to completion before the server starts:

cd ~/affine
cat > docker-compose.yml <<'EOF'
services:
  affine:
    image: ghcr.io/toeverything/affine:0.27.4
    restart: unless-stopped
    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"
  affine_migration:
    image: ghcr.io/toeverything/affine:0.27.4
    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:
      redis:
        condition: service_healthy
      postgres:
        condition: service_healthy
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
  postgres:
    image: pgvector/pgvector:pg16
    restart: unless-stopped
    environment:
      POSTGRES_USER: affine
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: affine
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes: ["pg_data:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "affine", "-d", "affine"]
      interval: 10s
      timeout: 5s
      retries: 5
  caddy:
    image: caddy:2
    restart: always
    ports: ["80:80", "443:443"]
    environment:
      DOMAIN: ${DOMAIN}
    volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
    depends_on: [affine]
volumes:
  pg_data:
  caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
    encode zstd gzip
    reverse_proxy affine:3010
}
EOF

Start it and wait for the server to answer. The migration job runs first, so the first start takes a little longer, and the loop is bounded so a broken install fails instead of hanging:

cd ~/affine
sudo docker compose up -d
code=000
for i in $(seq 1 60); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3010/info)
  [ "$code" = "200" ] && break
  sleep 3
done
echo "AFFiNE answered HTTP $code"
curl -s http://127.0.0.1:3010/info; echo
sudo docker compose ps -a

HTTP 200 and an AFFiNE ... Server line mean the server is up; in the ps -a listing the migration job shows Exited (0), which is correct, since it is a one-shot job. If the server never answers, jump to Troubleshooting.

HTTPS + domain

Create an A record for your hostname (say notes.example.com) pointing at the server's public IP, and confirm it resolves before Caddy tries to get a certificate:

dig +short notes.example.com

Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:

. ~/affine/.env
out=000
for i in $(seq 1 20); do
  out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/info") && [ "${out%% *}" = "200" ] && break
  sleep 5
done
echo "HTTPS status and certificate check: $out"

A second number of 0 means the certificate verified. Do this before creating the administrator, so every link AFFiNE generates uses the https address from the start.

Create the administrator

On a fresh install the server is uninitialised and the first account created becomes the instance administrator, so do it right after HTTPS works, before anyone else finds the address. The instance reports whether that has happened:

. ~/affine/.env
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"query":"{ serverConfig { initialized } }"}' "https://$DOMAIN/graphql"; echo

"initialized":false means nobody has claimed it. The normal route is the browser: open https://notes.example.com and register the first account. If you prefer to create it from the server, there is a setup endpoint that only works while the instance has no users. This sends the credentials from .env in the request body (read from standard input, so the password never appears in a process list):

. ~/affine/.env
curl -s -X POST -H 'Content-Type: application/json' --data @- "https://$DOMAIN/api/setup/create-admin-user" <<EOF; echo
{"email":"$ADMIN_EMAIL","password":"$ADMIN_PASSWORD"}
EOF
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"query":"{ serverConfig { initialized } }"}' "https://$DOMAIN/graphql"; echo

The second query should now report "initialized":true. That endpoint is AFFiNE's first-run setup request rather than a documented public API, so it may change between releases; we confirmed in our run that it created the account and flipped the flag. In a manual check after the run, signing in with those credentials returned HTTP 200 and a wrong password returned 400. Using the web app as a person would, and email delivery, were not tested. Use grep ADMIN_PASSWORD ~/affine/.env to print the password, sign in, then change it and store it in your password manager.

After the first account exists:

  1. Open the admin panel and review who can register. Turn open sign-up off, or restrict it, once your team has joined; an open sign-up page on a workspace tool is an invitation.
  2. Configure SMTP in the admin panel so invitations and password resets reach people. Without it you have to hand out invitation links yourself.
  3. Install the desktop or mobile client and point it at your own address to use the local-first sync against your server.

Backups

State lives in three places: the PostgreSQL database, the blob storage in ~/affine/data/storage (uploaded images and attachments) and your config and .env. A database dump without the storage directory restores documents with missing images, so back up all three. These archives hold your team's notes and the passwords, so they are created readable by their owner only. Start with the database dump; the password is read inside the container, so it never appears on the host command line:

cd ~/affine
( umask 077; sudo docker compose exec -T postgres sh -c 'pg_dump -U affine -Fc affine' > ~/affine-db-$(date +%F).dump )
test -s ~/affine-db-$(date +%F).dump && ls -lh ~/affine-db-*.dump

Then archive the storage directory and the configuration:

cd ~/affine
( umask 077
  sudo tar czf - data/storage > ~/affine-storage-$(date +%F).tar.gz
  tar czf ~/affine-config-$(date +%F).tar.gz docker-compose.yml Caddyfile config .env )
ls -lh ~/affine-storage-*.tar.gz ~/affine-config-*.tar.gz

Copy these files off the server, to encrypted object storage or another machine, and run the set from cron. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway PostgreSQL container (with the same pgvector image), restores the dump into it and counts the tables it created:

cd ~
sudo docker rm -f affine-restore-test >/dev/null 2>&1
sudo docker run -d --name affine-restore-test -e POSTGRES_PASSWORD=scratch pgvector/pgvector:pg16 >/dev/null
for i in $(seq 1 40); do
  sudo docker exec affine-restore-test pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && break
  sleep 2
done
sudo docker exec affine-restore-test createdb -U postgres affine
sudo docker exec -i affine-restore-test pg_restore -U postgres -d affine --no-owner < ~/affine-db-$(date +%F).dump
sudo docker exec affine-restore-test psql -U postgres -d affine -At \
  -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'"
sudo docker rm -f affine-restore-test >/dev/null

It should print a number well above zero (it printed 91 in this guide's verification run). The check proves the dump loads and the schema is intact; it does not start AFFiNE against the restored database or check the storage archive. For a real restore onto a new server, install Docker, unpack the config archive into ~/affine, unpack the storage archive, start only the database (sudo docker compose up -d postgres), pg_restore the dump into it, then start everything.

Upgrades

Because the image tag is pinned, an upgrade is an edit. Check AFFiNE's release notes, take a backup (previous section), change both ghcr.io/toeverything/affine tags in docker-compose.yml (the server and the migration job must match), then pull and recreate:

cd ~/affine
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs --tail 20 affine

The migration job runs on every up, applies any new database migrations and exits; those are one-way, which is why the backup comes first. Update the desktop and mobile clients around the same time, since a client and server that are far apart in version can refuse to sync. Keep the postgres major version pinned too; a new PostgreSQL major is a dump-and-restore job, not a tag bump.

Troubleshooting

The site returns 502 through Caddy, or never answers. Check sudo docker compose ps -a. If affine_migration shows an error, read sudo docker compose logs affine_migration; the server will not start until the migration job exits successfully. A wrong database password (the .env was regenerated after the database volume already existed) is the usual cause. Restore the original .env, or remove the volumes if the install holds no data yet.

Invite and share links point to the wrong address. externalUrl in config/config.json does not match the address you browse to. Fix it, then restart with sudo docker compose up -d.

Caddy never gets a certificate. Confirm dig +short notes.example.com returns the server's IP and that your provider's network-level firewall, not just ufw, allows inbound 80 and 443. A blocked port 80 silently breaks the ACME challenge.

The server is killed or very slow. Run sudo docker stats and dmesg | grep -i oom. An out-of-memory kill means the server is too small for your workspaces; move up a tier rather than adding swap to a database host.

The desktop client cannot connect. It needs the full HTTPS address, including https://, and a certificate from a public authority. A self-signed certificate on the server will be rejected by the client.

Verification + next steps

You're done when https://notes.example.com loads over a valid certificate, initialized reports true, you can sign in as the administrator, docker compose ps shows every service up after a reboot (restart policies bring them back), and you have restored a backup onto a scratch box at least once.

From there, create a workspace, invite your team, import existing notes and set up the desktop client. See the AFFiNE entry for catalog details and Best VPS for self-hosting for the ranked server picks.

Next steps

How to self-host AFFiNE →More self-hosted notes tools →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghost on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy a GitHub Actions Self-Hosted Runner on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Invoice Ninja on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy WordPress on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.