How to Deploy AFFiNE on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-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.
- 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
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.)
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:
- 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.
- Configure SMTP in the admin panel so invitations and password resets reach people. Without it you have to hand out invitation links yourself.
- 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.