Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

How to Self-Host AFFiNE with Docker

Published

Dockervps
affine

AFFiNE combines documents and an edgeless canvas in one workspace. Running it on your own server keeps the application, PostgreSQL database and uploaded files under your control. Start with a private browser connection through SSH, then verify that your workspace is actually stored on the server rather than only in the browser.

Use a fresh Ubuntu 24.04 server with sudo access and an SSH client on your computer. For a small workspace, choose an InterServer VPS with four CPU cores, at least 4 GB RAM and room for uploaded files. Larger documents and more users need additional memory and storage. This setup does not configure public sharing, email delivery or AI-provider accounts.

Private-port prerequisite: use Docker Engine 28 or newer for the loopback-published ports in this guide. Older releases have a documented localhost-port exposure limitation. Confirm from another machine that the public IP cannot reach those private service ports; a proxy login does not protect a direct bypass route.

1. Prepare the deployment directory

On the server, install Docker and Compose:

$ sudo apt-get update
$ sudo apt-get install -y docker.io docker-compose-v2
$ sudo systemctl enable --now docker
$ sudo install -d -m 0700 -o "$USER" -g "$USER" /opt/affine
$ cd /opt/affine
$ mkdir -p config

Keep /opt/affine in place after deployment. The database, uploaded files and application configuration will live beneath it. Use this layout for a new installation; do not replace an older AFFiNE deployment’s Compose file without checking its existing data paths and database credentials.

2. Define the application stack

Create /opt/affine/compose.yaml:

name: affine
services:
  affine:
    image: ghcr.io/toeverything/affine:0.27.4
    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@postgres:5432/affine
      AFFINE_INDEXER_ENABLED: "false"
    restart: unless-stopped

  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@postgres:5432/affine
      AFFINE_INDEXER_ENABLED: "false"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  redis:
    image: redis:7.4-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  postgres:
    image: pgvector/pgvector:pg16
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: affine
      POSTGRES_DB: affine
      POSTGRES_INITDB_ARGS: "--data-checksums"
      POSTGRES_HOST_AUTH_METHOD: trust
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "affine", "-d", "affine"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

The application and migration job use the same AFFiNE version. PostgreSQL includes the required vector extension, while Redis supports application coordination. Only the app’s HTTP port is published, and it binds to 127.0.0.1.

This follows the upstream private-network database layout: PostgreSQL uses trust authentication between containers on this Compose network. Do not publish its port or attach untrusted containers to that network. Redis is also unpublished. Host administrators and members of the Docker group remain trusted.

Create /opt/affine/config/config.json:

{
  "$schema": "https://github.com/toeverything/AFFiNE/releases/download/v0.27.4/config.schema.json",
  "server": {
    "name": "Private AFFiNE",
    "externalUrl": "http://localhost:13010"
  },
  "copilot": {
    "enabled": false,
    "byok": {
      "enabled": false
    }
  }
}

server.externalUrl must match the URL used in the browser. Here it matches the local end of the SSH tunnel. AI features are disabled, so document and canvas work does not require a provider key. New installations using this layout do not need a separate .env file.

Understand where the workspace lives

There are two kinds of persistence to distinguish. A browser-only workspace can keep content locally in that browser. A server-backed workspace stores its synchronized state in your self-hosted deployment and can be reopened from a separate signed-in browser. Seeing a document on screen is therefore not enough to establish that it reached the server.

The deployment also separates database content from uploaded files. Keep the PostgreSQL data directory and AFFiNE storage directory together with the configuration when planning protection of the instance. Preserving only the Compose file can recreate containers, but it cannot recreate documents and attachments that existed only in the data directories.

The migration service is a one-shot preparation job, not another web server. Its successful exit is expected and is different from the main application unexpectedly stopping. Inspect that job’s result alongside the health of PostgreSQL and Redis before diagnosing a browser connection problem.

3. Start AFFiNE

Run:

$ sudo docker compose -f /opt/affine/compose.yaml config --quiet
$ sudo docker compose -f /opt/affine/compose.yaml pull
$ sudo docker compose -f /opt/affine/compose.yaml up -d
$ sudo docker compose -f /opt/affine/compose.yaml ps -a
$ sudo docker compose -f /opt/affine/compose.yaml logs --tail=100 affine affine_migration

The migration job should finish with exit code 0. That stopped container is expected. The application, Redis and PostgreSQL should remain running; the two supporting services should report healthy. Wait for the application to finish startup before opening the browser.

If migration fails, read its logs before retrying. Check free disk space and database health. Do not delete the data directory to fix an unexplained startup error.

4. Create the administrator and workspace

On your computer, open an SSH tunnel, replacing the login and address:

$ ssh -N -L 13010:127.0.0.1:3010 user@SERVER_IP

Keep the terminal open and visit http://localhost:13010/admin. Enter your name, email address and a strong password, then click Continue through the setup screens. Wait for the login page and sign in with that administrator account. Complete this before giving anyone else access to the instance. This private setup does not configure SMTP, so do not rely on email-based password recovery.

Open http://localhost:13010 in the same browser. The initial Demo Workspace is browser-only. Click Enable AFFiNE Sync, then Enable to move it to your self-hosted server. Wait for synchronization to finish. Keep documents private; do not enable public publishing.

Click the + next to Search to create a document named Server notes, then enter a short paragraph. On a new line, type /attachment, choose Attachment and select a small test file. Click the attachment, then its Download icon, and check the downloaded contents. Switch to the edgeless view with the canvas icon beside the document icon. Choose a shape from the bottom toolbar, drag it onto the canvas, then double-click it to enter text.

Next, open a separate browser profile or private window, sign in through the same /admin page, and reopen the workspace. Confirm that the document, canvas item and attachment are present. This catches a common mistake: content visible in one browser may still belong to a local workspace rather than your server.

Use a fresh browser to confirm server storage

Complete the sync conversion before relying on the cross-browser check. Reopening the same tab or another tab in the same profile can still show local browser state. A separate profile or private session makes a stronger check because it must obtain the workspace through the signed-in server connection.

Check more than the document title. Read the paragraph, inspect the canvas item and download the attachment so each kind of content has a concrete result. A visible attachment name alone does not establish that its bytes are retrievable. If the second session does not show the content, confirm that it uses the same instance and account and that the initial workspace finished syncing.

Keep the tunnel URL and configured external URL aligned. In this setup, localhost in the browser belongs to your computer, while the forward connects to the application’s loopback port on the VPS. A closed tunnel can make a healthy remote app look unavailable; it does not mean the database has disappeared.

5. Verify persistent storage

Recreate the containers without deleting the deployment directories:

$ sudo docker compose -f /opt/affine/compose.yaml up -d --force-recreate
$ sudo docker compose -f /opt/affine/compose.yaml ps -a

Wait for migration and application startup again. In a fresh signed-in browser session, reopen Server notes and download its attachment. The content should still be there.

Keep data/postgres, data/storage and config together. Container recreation is not a backup: arrange independent backups before storing important work. The database and uploaded files must both be protected.

Close the tunnel with Ctrl+C when finished. If the page stops loading, reopen the tunnel and confirm the stack is running. If a different local port is necessary, update server.externalUrl to the browser URL and recreate the app before using that address. Do not expose port 3010 publicly as a shortcut. Public access needs a trusted HTTPS reverse proxy with WebSocket support and a matching external URL.

Related guides

Account services are unavailable until the production backend is configured.