Skip to Content

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

A production-focused runbook for secure setup, storage, backups, and operational verification.

Many teams outgrow ad-hoc documentation in chat threads and scattered docs once they hit 10–30 engineers, customer support handoffs, and compliance checklists. Outline gives you a clean internal knowledge base with search, collections, and permissions, but most deployment guides stop at “it runs” and skip the operational guardrails you need in production. This guide walks through a production-focused Outline deployment on Ubuntu using Docker Compose, Caddy for TLS/reverse proxy, and PostgreSQL for durable storage, with Redis and MinIO included for app reliability and file handling.

Architecture and flow overview

This stack separates traffic, app runtime, and stateful services so each layer can be monitored, backed up, and upgraded with less risk. Caddy terminates TLS and handles public ingress. Outline serves the app itself. PostgreSQL stores structured data (users, collections, permissions, revisions). Redis handles cache/queue behavior expected by Outline. MinIO provides S3-compatible object storage for attachments so documents remain portable and backups stay predictable. Persistent volumes are isolated per service, and all containers communicate on a dedicated Docker network.

Traffic flow is: browser → Caddy (HTTPS) → Outline container. Internal service flow is: Outline → PostgreSQL + Redis + MinIO. Backup flow is: periodic pg_dump + MinIO bucket sync + encrypted off-host copy.

Prerequisites

  • Ubuntu 22.04/24.04 VM with at least 2 vCPU, 4 GB RAM, and 40+ GB disk
  • DNS A record (e.g., wiki.example.com) pointing to your server
  • Firewall allowing 22, 80, and 443 from trusted sources
  • Docker Engine + Docker Compose plugin installed
  • A dedicated Unix account (non-root) with sudo access
  • Ability to store encrypted backups off-host (object storage or backup node)

Use a fresh host if possible. Mixing this stack with unrelated containers increases blast radius and troubleshooting complexity.

Step 1: Prepare host and baseline security

Start with package updates, base hardening, and a sane firewall policy. Keep host prep simple but deliberate; the goal is stable operations, not maximum novelty.

sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw fail2ban
sudo timedatectl set-timezone UTC
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 --force enable

Manual copy fallback: if the button is unavailable in your theme, select inside the code block and copy normally.

Create a working directory with strict permissions. Store all Compose assets there so upgrades and backups are deterministic.

sudo mkdir -p /opt/outline/{caddy,data/postgres,data/redis,data/minio,backups}
sudo chown -R $USER:$USER /opt/outline
chmod -R 750 /opt/outline
cd /opt/outline

Manual copy fallback: copy directly from the terminal-style block above.

Step 2: Create Compose stack for Outline + PostgreSQL + Redis + MinIO + Caddy

The compose file below uses explicit health checks and restart policies so service ordering is less fragile after reboots. Keep versions pinned in production and roll forward intentionally during maintenance windows.

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

  redis:
    image: redis:7-alpine
    container_name: outline-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./data/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  minio:
    image: minio/minio:latest
    container_name: outline-minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    volumes:
      - ./data/minio:/data

  outline:
    image: outlinewiki/outline:latest
    container_name: outline-app
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      minio:
        condition: service_started
    env_file:
      - .env

  caddy:
    image: caddy:2
    container_name: outline-caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy/data:/data
      - ./caddy/config:/config
    depends_on:
      - outline

networks:
  default:
    name: outline-net
EOF

Manual copy fallback: if copy controls are stripped, manually highlight the YAML and paste into /opt/outline/docker-compose.yml.

Step 3: Configure environment variables and secrets handling

Do not hardcode secrets in compose YAML. Keep them in .env, restrict file permissions, and rotate high-risk keys on a schedule (or immediately after suspected exposure). For production, generate long random values and store the canonical copy in a secrets manager.

cat > /opt/outline/.env << 'EOF'
# Core app
NODE_ENV=production
URL=https://wiki.example.com
PORT=3000
SECRET_KEY=replace_with_64_plus_random_chars
UTILS_SECRET=replace_with_64_plus_random_chars

# Database / cache
DATABASE_URL=postgres://outline:${POSTGRES_PASSWORD}@postgres:5432/outline
REDIS_URL=redis://redis:6379

# Email (example SMTP)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
[email protected]
SMTP_PASSWORD=replace_with_smtp_password
[email protected]
[email protected]

# Object storage via MinIO
AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
AWS_REGION=us-east-1
AWS_S3_UPLOAD_BUCKET_NAME=outline
AWS_S3_UPLOAD_BUCKET_URL=https://wiki.example.com/storage
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private

# MinIO bootstrap
MINIO_ROOT_USER=replace_with_minio_admin
MINIO_ROOT_PASSWORD=replace_with_strong_minio_password
POSTGRES_PASSWORD=replace_with_strong_postgres_password
EOF
chmod 600 /opt/outline/.env

Manual copy fallback: you can paste this block into a text editor and save as /opt/outline/.env.

For mature environments, load secrets from Docker secrets or external secret stores and template your env file during deployment. The essential requirement is to avoid secret leakage into shell history, public repos, or support tickets.

Step 4: Configure Caddy reverse proxy and TLS

Caddy simplifies certificate issuance/renewal and gives clean HTTPS defaults. Keep reverse proxy config small and explicit so incidents are easier to reason about.

cat > /opt/outline/caddy/Caddyfile << 'EOF'
wiki.example.com {
  encode gzip zstd

  @storage path /storage*
  handle @storage {
    reverse_proxy minio:9000
  }

  reverse_proxy outline:3000

  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"
  }
}
EOF

Manual copy fallback: copy and paste the Caddyfile content manually if needed.

If your DNS is behind a proxy/CDN, verify your TLS mode and origin settings first; mismatches here are a common reason for redirect loops and failed login callbacks.

Step 5: Start services and bootstrap MinIO bucket

Bring up the stack, check health, then create the object bucket once. Keep this sequence consistent across environments to reduce one-off drift.

cd /opt/outline
docker compose pull
docker compose up -d
docker compose ps

docker run --rm --network outline-net \
  minio/mc sh -c "
  mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
  mc mb -p local/outline || true
  "

Manual copy fallback: execute line-by-line in your shell if one-click copy is unavailable.

After first startup, complete Outline’s web onboarding, create the initial admin user, and connect your authentication provider (email, Google, or other supported SSO) according to your organization’s policy.

Configuration and secret-management best practices

Production quality comes from repeatable controls, not just working containers. Apply these controls from day one:

  • Least privilege: use dedicated credentials per service; avoid shared root-like secrets across environments.
  • Rotation cadence: rotate SMTP/API/database secrets on a fixed schedule and after staff transitions.
  • Encrypted backups: encrypt database and object backups before off-host transfer.
  • Change control: pin image tags, test upgrades in staging, and maintain rollback notes.
  • Auditability: keep deployment manifests in version control with secrets excluded.

For compliance-sensitive teams, add access logs at the proxy layer, centralize logs, and preserve backup restore drills as auditable evidence.

Verification checklist (post-deploy and after every upgrade)

Run functional and operational checks. A green homepage alone does not prove readiness.

# container health
cd /opt/outline
docker compose ps

# HTTPS + redirect correctness
curl -I http://wiki.example.com
curl -I https://wiki.example.com

# app response
curl -s https://wiki.example.com | head -n 5

# recent logs for errors
docker compose logs --since=15m outline | tail -n 80
docker compose logs --since=15m caddy | tail -n 80

# backup smoke test (example)
pg_dump -h 127.0.0.1 -U outline outline > /opt/outline/backups/outline_$(date +%F).sql

Manual copy fallback: run each command directly in terminal if clipboard scripts are blocked by your CMS.

Additionally verify a real user workflow: login, create document, upload attachment, search it, and confirm revision history. That catches misconfigured storage and permissions earlier than passive monitoring.

Common issues and practical fixes

1) Login callback fails or loops

Usually caused by URL mismatch between Outline and proxy. Confirm URL=https://wiki.example.com exactly matches the public origin and that TLS is terminating cleanly at Caddy.

2) Attachments fail to upload

Check MinIO credentials, bucket existence, and Caddy route for /storage. Verify object storage env vars and path-style setting are consistent.

3) Database connection errors after restart

Validate DATABASE_URL, confirm PostgreSQL health status, and inspect volume permissions. Avoid editing container internals directly; fix configuration in source files.

4) Slow page loads under moderate usage

Inspect VM CPU credits, disk latency, and Redis persistence impact. Scale to dedicated managed PostgreSQL if sustained load grows beyond single-node comfort.

5) TLS certificate issuance problems

Confirm DNS points to the host and port 80 is reachable for ACME challenges. If behind a CDN, ensure origin reachability and SSL mode alignment.

6) Restore worked but users see missing files

This indicates database/object backup mismatch. Always restore database dump and MinIO bucket snapshot from the same checkpoint.

FAQ

Can I run Outline without MinIO?

Yes, but production teams usually need reliable attachment storage and backups. MinIO gives a self-hosted S3-compatible target that keeps architecture portable.

Is Docker Compose enough for production, or should I move to Kubernetes now?

Compose is often enough for small-to-medium internal deployments if you enforce backups, monitoring, and controlled upgrades. Move to Kubernetes when you have clear scaling, multi-tenant, or platform-standardization needs.

How often should I back up this stack?

At minimum: daily full PostgreSQL backup plus frequent object-store syncs. If knowledge churn is high, increase backup frequency and keep periodic immutable snapshots.

What is the safest upgrade approach?

Pin image tags, test in staging, take fresh backups, and upgrade one component set at a time. Always keep a rollback path with previous images and known-good config.

How do I secure admin access for a distributed team?

Use SSO with enforced MFA, restrict direct server access, and centralize audit logs. Keep least-privilege service accounts and avoid sharing credentials in chat.

Can I use managed PostgreSQL while keeping the rest self-hosted?

Absolutely. Many teams keep Outline/Caddy/MinIO on a VM and move PostgreSQL to a managed service for stronger backup and failover characteristics.

How do I validate disaster recovery beyond backups existing?

Run a scheduled restore drill in a separate environment, then verify login, search, attachments, permissions, and document history. A backup is only proven after a successful restore test.

Related Guides

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

How to Deploy Authentik with Docker Compose and Traefik (Production Guide)
Build a resilient self-hosted SSO stack with TLS, backups, and operational guardrails.