Skip to Content

Production Guide: Deploy AFFiNE with Docker Compose + Caddy + PostgreSQL on Ubuntu

A production-ready AFFiNE deployment blueprint with HTTPS, secrets handling, backups, and practical day-2 operations.

Many teams hit the same collaboration bottleneck: documents, notes, and project decisions are spread across chat threads, personal docs, and disconnected tools. AFFiNE is a strong open-source option when you want a Notion-like workspace with ownership and deployment control. In this guide, you will deploy AFFiNE on Ubuntu with Docker Compose, PostgreSQL, and Caddy so your team gets HTTPS by default, predictable operations, and a setup you can actually maintain in production.

This walkthrough is written for operators who need more than a quick demo. We will cover architecture choices, secret handling, backup strategy, health verification, and practical troubleshooting. By the end, you will have a reproducible deployment baseline you can harden further with SSO, centralized logs, and infrastructure-as-code workflows.

Architecture and flow overview

This deployment uses three containers on a private Docker network:

  • affine: the workspace application served over HTTP internally
  • postgres: persistent database for workspace content and metadata
  • caddy: reverse proxy with automatic TLS certificates and HTTPS redirects

Traffic flows from users to https://docs.yourdomain.com. Caddy terminates TLS, applies secure headers, and proxies requests to the AFFiNE container on the private network. AFFiNE connects to PostgreSQL through internal-only networking, so the database is never exposed directly on the public internet. This model reduces attack surface and keeps responsibilities clear: proxy for transport security, app for business logic, database for durable state.

From an operational perspective, the key production principles are simple: keep state in explicit host-mounted volumes, pin image versions intentionally, isolate secrets from source control, and validate every deploy with a short smoke checklist before inviting users. Reliability usually comes from consistency, not complexity.

Prerequisites

  • Ubuntu 22.04 or 24.04 server (recommended minimum: 2 vCPU, 4 GB RAM, 40+ GB disk)
  • A domain or subdomain (example: docs.yourdomain.com) pointing to server IP
  • Ports 80 and 443 open in cloud firewall/security group
  • SSH access with sudo privileges
  • Docker Engine + Docker Compose plugin installed
  • Basic backup target (remote host or object storage) for database dump retention
sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw
sudo mkdir -m 0755 -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER

If the copy button does not work in your browser/editor, manually select the command block and copy it.

Step-by-step deployment

1) Create project structure and protected environment file

Use explicit directories so upgrades, backup jobs, and permission audits stay straightforward. Restrict environment file permissions immediately so secrets are not world-readable.

mkdir -p ~/affine-prod/{caddy,affine-data,postgres-data,backups}
cd ~/affine-prod
touch .env
chmod 600 .env

If the copy button does not work in your browser/editor, manually select the command block and copy it.

Edit .env and use strong random credentials. Keep per-environment secrets unique so dev/stage leaks do not impact production.

cat > .env << 'EOF'
DOMAIN=docs.yourdomain.com
TZ=America/Chicago
POSTGRES_DB=affine
POSTGRES_USER=affine
POSTGRES_PASSWORD=replace_with_32plus_random_chars
AFFINE_DB_URL=postgres://affine:replace_with_32plus_random_chars@postgres:5432/affine
AFFINE_SERVER_PORT=3010
EOF

If the copy button does not work in your browser/editor, manually select the command block and copy it.

2) Create Docker Compose stack

This compose file keeps PostgreSQL internal-only, adds health checks, and mounts durable volumes for both app data and database state. Pin image versions intentionally for predictable upgrades.

cat > docker-compose.yml << 'EOF'
services:
  postgres:
    image: postgres:16
    container_name: affine-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: ${TZ}
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - internal

  affine:
    image: ghcr.io/toeverything/affine-self-hosted:stable
    container_name: affine-app
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: ${AFFINE_DB_URL}
      AFFINE_SERVER_PORT: ${AFFINE_SERVER_PORT}
      NODE_ENV: production
      TZ: ${TZ}
    volumes:
      - ./affine-data:/root/.affine
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:${AFFINE_SERVER_PORT}/ || exit 1"]
      interval: 15s
      timeout: 5s
      retries: 15
    networks:
      - internal

  caddy:
    image: caddy:2.8
    container_name: affine-caddy
    restart: unless-stopped
    depends_on:
      affine:
        condition: service_started
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy/data:/data
      - ./caddy/config:/config
    networks:
      - internal

networks:
  internal:
    driver: bridge
EOF

If the copy button does not work in your browser/editor, manually select the command block and copy it.

3) Configure Caddy reverse proxy and security headers

Caddy simplifies certificate issuance and renewal while keeping the configuration readable. The security headers below are a practical baseline for most internal collaboration deployments.

mkdir -p caddy
cat > caddy/Caddyfile << 'EOF'
{$DOMAIN} {
    encode zstd gzip

    reverse_proxy affine:${AFFINE_SERVER_PORT}

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
    }

    log {
        output stdout
        format console
    }
}
EOF

If the copy button does not work in your browser/editor, manually select the command block and copy it.

4) Launch stack and run first health checks

Bring the stack up, confirm container health, and inspect logs before opening access to your wider team. Catching misconfigurations now avoids painful migrations later.

export $(grep -v '^#' .env | xargs)
docker compose up -d
docker compose ps
docker compose logs --tail=120 affine
docker compose logs --tail=80 caddy

If the copy button does not work in your browser/editor, manually select the command block and copy it.

5) Host firewall and access hardening

Do not rely only on container networking for security. Enforce host-level policy so only SSH and web ingress are exposed. If possible, restrict SSH source IP ranges and disable password authentication at the OS level.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

If the copy button does not work in your browser/editor, manually select the command block and copy it.

6) Add scheduled PostgreSQL backups

Backups are part of deployment, not an afterthought. A practical baseline is daily compressed dumps plus retention cleanup and off-host sync. Test restoration monthly to verify backup quality.

cat > ~/affine-prod/backup-postgres.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd ~/affine-prod
export $(grep -v '^#' .env | xargs)
TS=$(date +%F-%H%M%S)
OUT="./backups/affine-${TS}.dump"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" affine-postgres \
  pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "$OUT"
find ./backups -type f -name 'affine-*.dump' -mtime +14 -delete
EOF
chmod +x ~/affine-prod/backup-postgres.sh
( crontab -l 2>/dev/null; echo "20 2 * * * ~/affine-prod/backup-postgres.sh" ) | crontab -

If the copy button does not work in your browser/editor, manually select the command block and copy it.

Configuration and secrets handling best practices

Most production incidents in self-hosted collaboration stacks are caused by weak secret hygiene or untested change processes, not exotic software bugs. Keep these controls in place from day one:

  • Never commit .env to Git. Add it to .gitignore and keep a sanitized .env.example for documentation.
  • Rotate database credentials and admin access values on a schedule and immediately after team changes.
  • Use separate secrets per environment; no shared values across development, staging, and production.
  • Limit SSH to key-based authentication and maintain a reviewed admin access list.
  • For larger teams, place secrets in a vault and inject at runtime through CI/CD rather than manual edits.

Plan day-2 operations before go-live: define who owns upgrades, what the rollback trigger is, and which checks must pass after any change. A simple runbook can save hours during high-pressure incidents. At minimum, include: pre-upgrade backup step, image tag bump process, post-upgrade smoke tests, and rollback commands. If your team is remote, document where the runbook lives and who is primary on-call.

As usage grows, monitor storage, container restarts, and database bloat. Start lightweight if needed, but avoid β€œsilent drift” by setting a monthly maintenance window for patching, certificate validation review, and restore testing. Operational discipline matters more than fancy tooling in early production phases.

Verification checklist

  • DNS resolves docs.yourdomain.com to correct server IP
  • TLS certificate is valid and renewed by Caddy
  • AFFiNE web UI loads over HTTPS without mixed-content warnings
  • Database container reports healthy and accepts connections
  • Backup script runs successfully and creates restorable dump files
  • Firewall exposes only expected ports (22, 80, 443)
curl -I https://docs.yourdomain.com
docker compose ps
docker exec -it affine-postgres pg_isready -U affine -d affine
ls -lh ~/affine-prod/backups | tail -n 5
sudo ufw status verbose

If the copy button does not work in your browser/editor, manually select the command block and copy it.

Common issues and fixes

AFFiNE container starts but the site shows 502 from Caddy

Usually this is an internal port mismatch. Confirm AFFINE_SERVER_PORT in .env, the compose environment value, and the Caddy reverse_proxy target all match. Then restart only app + proxy and re-check logs.

Database connection errors during startup

Most failures are credential typos or special characters in passwords not handled in URL strings. Rebuild the database URL carefully, and if needed URL-encode special characters. Verify by opening a shell in the app container and testing TCP connectivity to postgres:5432.

TLS certificate is not issued

Check DNS first, then verify ports 80/443 are publicly reachable. If you are behind a CDN or proxy service, temporarily switch to DNS-only mode for initial issuance and re-enable proxying after certificate generation succeeds.

Backups run but restore fails

A dump file existing is not enough. Test restore into a throwaway PostgreSQL container monthly. Confirm application starts and key workspaces load. Add restore validation to your ops checklist so this does not get skipped.

Users report slow page loads after growth

Check host saturation (CPU, memory, disk I/O), database tuning, and noisy neighbors on shared infrastructure. Often the first fix is simply moving to better storage and right-sized resources, then layering observability dashboards for trend visibility.

FAQ

Can I run this with Nginx or Traefik instead of Caddy?

Yes. The AFFiNE app and PostgreSQL pieces are the same, and only the reverse-proxy layer changes. Choose the proxy your team can maintain confidently; reliability comes from operational familiarity.

Is PostgreSQL required, or can I use SQLite in production?

Use PostgreSQL for production. It handles concurrency and operational tooling far better for team collaboration workloads. SQLite can work for local testing, but it is not ideal for multi-user production systems.

How should we handle upgrades safely?

Pin image tags, run a pre-upgrade backup, deploy in a maintenance window, and verify core workflows immediately after rollout. Keep rollback steps documented and tested, not just implied.

What is a reasonable backup retention policy for small teams?

A practical starting point is daily dumps retained for 14 days plus weekly copies retained for 8 weeks, replicated off-host. Increase retention when compliance or audit requirements apply.

Can we add SSO later without reinstalling?

Yes. Most teams start with local auth and introduce SSO once identity requirements mature. Plan this early in architecture docs so permission mapping and onboarding flow are clear before migration.

How do we separate staging and production cleanly?

Use separate hosts or projects, separate databases, separate secrets, and distinct domains (for example, docs-staging and docs). Avoid shared credentials and avoid copy-paste deployment drift by templating config files.

What metrics should we monitor first?

Container restarts, memory pressure, database health, certificate renewal status, and backup job success/failure. These indicators catch most operational failures early without overwhelming a small team.

Related guides

Talk to us

If you want support designing or hardening your collaboration platform, we can help with architecture, migration planning, and production readiness.

Contact Us

Production Guide: Deploy Uptime Kuma with Rootless Podman + systemd + Caddy on Ubuntu
A practical production deployment with rootless containers, systemd reliability, HTTPS, backups, and day-2 operations.