All posts

Outline wiki on a VPS

Self-host Outline 1.12.0 with Postgres, Redis, and OIDC: no local passwords, URL match, local or S3 files, idle ~358 MiB. Notion-shaped docs on your VPS.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

Outline 1.12.0 on this lab answered HTTP 200 at http://127.0.0.1:3030/ after Postgres migrations. Logs showed OIDC plugin registered and Listening on http://127.0.0.1:3030. Dex (lab IdP) served OpenID discovery at http://127.0.0.1:5556/dex and returned HTTP 302 from /dex/auth. Quiet medians: Outline ~323 MiB, Postgres ~31, Redis ~4 (~358 MiB without Dex; ~366 with the lab IdP). There is no local username/password screen. If you do not wire an OIDC provider, you do not get in.

The contrast with BookStack is login: BookStack ships a default admin; Outline assumes Google, Slack, Azure AD, Authentik, Dex, or similar. Production on a VPS usually means Authentik (or another IdP you already run) plus Postgres, Redis, and either local disk or S3-compatible storage for attachments.

ReadingValue
Imageoutlinewiki/outline:1.12.0
Database / cachepostgres:16-alpine · redis:7-alpine
Lab IdPdexidp/dex:v2.41.1
App listen127.0.0.1:3030 → 3000
HomeHTTP 200
OIDCplugin registered · Dex issuer OK
Idle app+pg+redis~358 MiB
Idle + Dex~366 MiB
Diagram of Outline, Postgres, Redis, and OIDC IdP with idle MiB.
Notion-shaped wiki. OIDC is mandatory, not optional polish.

Outline vs BookStack

OutlineBookStack
LoginOIDC (no built-in password)Local admin (LDAP optional)
ShapeCollections / docs (Notion-like)Books → chapters → pages
Quiet RAM (this series)~358 MiB~146 MiB
FilesLocal or S3App /config + DB
Extra servicesRedis requiredMariaDB only

Pick Outline when the team already lives in an IdP and wants realtime docs. Pick BookStack when you want a smaller stack and a password on day one.

Compose: Postgres, Redis, Outline, IdP

Lab used Dex so the stack could prove OIDC without standing up Authentik again. Production: point OIDC_* at Authentik (or Google) and drop Dex.

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: outline
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: outline
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U outline -d outline"]
      interval: 5s
      timeout: 5s
      retries: 12

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 12

  outline:
    image: outlinewiki/outline:1.12.0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file: .env
    volumes:
      - ./data:/var/lib/outline/data
    ports:
      - "127.0.0.1:3000:3000"

volumes:
  pgdata:

This lab mapped 3030 because host 3000 was already taken.

SECRET_KEY=$(openssl rand -hex 32)
UTILS_SECRET=$(openssl rand -hex 32)
# .env (mode 600) essentials
URL=http://127.0.0.1:3030
DATABASE_URL=postgres://outline:…@postgres:5432/outline
REDIS_URL=redis://redis:6379
SECRET_KEY=…
UTILS_SECRET=…
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
FORCE_HTTPS=false
OIDC_CLIENT_ID=outline
OIDC_CLIENT_SECRET=…
OIDC_AUTH_URI=http://127.0.0.1:5556/dex/auth
OIDC_TOKEN_URI=http://dex:5556/dex/token
OIDC_USERINFO_URI=http://dex:5556/dex/userinfo
OIDC_USERNAME_CLAIM=email
OIDC_DISPLAY_NAME=Dex
OIDC_SCOPES=openid profile email

Browser traffic hits OIDC_AUTH_URI on a host-reachable address. The Outline container hits token/userinfo over compose DNS (dex or your IdP hostname). Mismatch those and login fails after the consent screen.

docker compose up -d
docker logs -f outline-app

Wait for migrations, then OIDC plugin registered and Listening on….

compose excerpt with Outline, Postgres, Redis, and Dex.
Four containers in the lab. Production often keeps the IdP elsewhere.
env secrets URL DATABASE_URL REDIS_URL and split OIDC AUTH vs TOKEN URIs.
32-byte hex secrets. Split browser AUTH URI from in-network TOKEN URI.

Prove the app and the IdP

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3030/
# 200

curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration \
  | jq '{issuer, authorization_endpoint}'

Lab Dex issuer: http://127.0.0.1:5556/dex. Authorize endpoint returned HTTP 302 for a valid client request.

Register the redirect URI https://wiki.example.com/auth/oidc.callback (path may vary slightly by Outline version; match what Outline documents for your release) on the IdP before inviting humans.

curl home HTTP 200, OIDC plugin registered, Dex well-known issuer.
App up plus OIDC plugin. Homepage alone is not a login proof.

Idle RAM on this lab

ContainerMedian MiB
outline-app~323
outline-pg~31
outline-redis~4
outline-dex (lab only)~9
App + DB + Redis~358

Collaborative editing and uploads sit above this. A Compute Instance with 2 GB is a realistic floor for Outline alone; leave headroom if the IdP shares the box.

docker stats for outline app postgres redis dex totaling about 366 MiB.
Quiet after migrations. Heavier than BookStack; OIDC is the product trade.

Files: local vs S3

FILE_STORAGE=local is enough to learn the UI. For shared durability, use S3-compatible storage (MinIO, Garage, a cloud bucket) with FILE_STORAGE=s3 and the AWS_* / AWS_S3_* variables from Outline’s hosting docs. Path-style access matters for MinIO.

Caddy

wiki.example.com {
    reverse_proxy 127.0.0.1:3000
}
URL=https://wiki.example.com
FORCE_HTTPS=true

Recreate Outline after changing URL. Leave the app port closed on the panel firewall. TLS pattern matches Caddy HTTPS.

Caddyfile reverse_proxy to Outline with URL and OIDC callback notes.
Public HTTPS URL equals Outline URL. Callback URI must match.
Skip thisWhat this lab did
Expecting local password loginWired Dex OIDC
Publishing 3000 on all interfaces127.0.0.1 only
latest foreverPinned 1.12.0
Same hostname for AUTH and TOKEN blindlySplit host vs compose DNS
Skipping RedisRequired; healthchecked

OIDC and file storage are not optional trivia

Outline’s Docker docs treat Postgres, Redis, URL, DATABASE_URL, REDIS_URL, and SECRET_KEY as the minimum. There is no local password login: you wire Google, Slack, Azure, or a generic OIDC provider (OIDC_CLIENT_ID / SECRET plus auth, token, and userinfo URIs) and register https://your-host/auth/oidc.callback. That is why lab installs often sit Dex or Authentik beside Outline; without an IdP the UI never becomes a usable wiki.

Attachments default to FILE_STORAGE=local under /var/lib/outline/data (map a volume). For S3-compatible stores set FILE_STORAGE=s3 with path-style URLs for MinIO (AWS_S3_FORCE_PATH_STYLE=true). Outline’s own compatibility table marks MinIO and several clouds as fine; Cloudflare R2 and Backblaze show up as problem cases in community notes, so test put/get before you cut over. Changing URL after clients exist breaks OIDC redirects the same way Logto’s ENDPOINT does.

FAQ

Can I skip OIDC? Not for a normal self-hosted login. Outline is built around providers.

Slack/Google only? Fine if those IdPs fit your threat model. On-prem teams usually prefer Authentik/Keycloak/Dex.

Enterprise image? Separate commercial image; this lab is community outlinewiki/outline.

Backups? pg_dump plus the file volume or S3 bucket. Off-box with Restic.

What to run on the VPS

Pin Outline 1.12.0, Postgres 16, Redis 7. Generate SECRET_KEY / UTILS_SECRET. Set URL to the final HTTPS hostname before users exist. Point OIDC at Authentik (or Dex for a lab). Keep the app on loopback behind Caddy. A 2 GB+ Compute Instance covers this quiet footprint with room for a small team; size up when collaboration and attachments grow.