Use case · Team knowledge base

Outline wiki self hosted: Compose, sign-in, proxy and backups on one machine

A self-hosted Outline wiki is one Docker container next to PostgreSQL 14+ and Redis 4+, configured through environment variables, published over HTTPS by a reverse proxy that passes websockets, with an external sign-in provider in front because Outline has no native email-and-password login. Below: the requirements as documented, a Compose file that keeps the app on 127.0.0.1, secrets that never touch the shell history, and the backup set that actually restores.

See the machines

What Outline requires

Outline is a Node.js application that keeps documents in PostgreSQL, uses Redis alongside it, and stores uploads on local disk or in an S3 bucket. The project ships an Alpine-based Docker image on a monthly release cadence and calls Docker the recommended, supported install method.

ItemDocumented requirement
Operating systemAny Unix that runs the dependencies; Ubuntu and Debian reported in production. Windows Server is not supported
DatabasePostgreSQL v14 or newer
Cache and queueRedis v4 or newer; one instance is enough for a self-hosted setup
Memory512 MB minimum; 1 GB or more recommended depending on users
CPUNo figure; scales with how many users are online and editing
NetworkA dedicated domain or subdomain, HTTPS, websockets through any proxy
IdentityA compatible authentication provider

Real-time editing runs as one collaboration process inside the container. The scaling guide calls that sufficient for teams under roughly 1,000 active users, with the exact ceiling depending on the hardware. The docs also warn that the platform is engineered for horizontal scale and expects dev-ops experience to run in production.

Authentication comes before installation

Outline has no password login: people sign in through a provider or an emailed link, and may add a passkey once they are in. Pick the provider before you start: a self-hosted install supports one team, and the docs note that once you have authenticated, no further team can be created.

ProviderVariablesCondition
Any OIDC server (Keycloak, authentik, GitLab and others)OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER_URL for auto-discoveryAll editions
GoogleGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGoogle Workspace accounts only
Microsoft Entra IDClient id and secret from an app registrationRegistered for accounts in your own directory only
Slack, DiscordSLACK_* or DISCORD_* client id and secretSlack requires an HTTPS redirect URL; Discord from v0.78.0
Email magic linkSMTP_* settingsEnabled once SMTP works and the option is on under Settings → Authentication
PasskeysNoneNot a first sign-in. v1.2.0 and later; after signing in another way, each user registers a key under Settings → Account → Passkeys; older workspaces switch them on under Settings → Workspace → Authentication
SAML (Okta, OneLogin)SAML_SSO_ENDPOINT, SAML_CERTPaid Business and Enterprise editions only

If you already run an identity server, OIDC is the cleanest route: see self-hosted Keycloak or authentik. After the first login, add your domains under Settings → Security so nobody else's accounts can join.

Firewall and secrets

  1. Install Docker Engine with the Compose plugin from Docker's own package repository.
  2. Before enabling ufw, check its config file /etc/default/ufw: with IPV6=yes there, ufw writes every rule for IPv6 too. Rules: ufw allow 80/tcp for certificate issuance, ufw allow OpenSSH, and for now 443 only from your own address, e.g. ufw allow from 203.0.113.10 to any port 443 proto tcp. Run ufw --force enable (no y/n prompt to swallow a pasted line) and check ufw status verbose.
  3. As root, create the install directory and the .env file. Each secret comes from openssl rand inside a command substitution, so no value is typed, printed or stored in shell history.
# as root (sudo -i)
mkdir -p /opt/outline/data && cd /opt/outline
chown 1001 data        # the uid Outline writes uploads as
umask 077
pg=$(openssl rand -hex 24)
{
  printf 'OUTLINE_TAG=\n'
  printf 'URL=https://wiki.example.com\n'
  printf 'PORT=3000\n'
  printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)"
  printf 'UTILS_SECRET=%s\n' "$(openssl rand -hex 32)"
  printf 'POSTGRES_PASSWORD=%s\n' "$pg"
  printf 'DATABASE_URL=postgres://outline:%s@postgres:5432/outline\n' "$pg"
  printf 'PGSSLMODE=disable\n'
  printf 'REDIS_URL=redis://redis:6379\n'
  printf 'FILE_STORAGE=local\n'
  printf 'FORCE_HTTPS=true\n'
} > .env
unset pg
chmod 600 .env
ls -l .env             # -rw------- root root

SECRET_KEY must be a hex-encoded 32-byte key, which is what openssl rand -hex 32 produces; a badly formatted key produces the "Invalid key length" error listed on the troubleshooting page. UTILS_SECRET guards the scheduled-job endpoints. PGSSLMODE=disable is acceptable, per the sample config, when the database sits on the same machine. Edit the file with an editor to fill OUTLINE_TAG with the newest version from the releases page and to add provider and SMTP values; never pass them as echo arguments.

The Compose file

# /opt/outline/docker-compose.yml
services:
  outline:
    image: docker.getoutline.com/outlinewiki/outline:${OUTLINE_TAG:?set OUTLINE_TAG in .env}
    env_file: .env
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - ./data:/var/lib/outline/data
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  redis:
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
    restart: unless-stopped

  postgres:
    image: postgres:18
    environment:
      POSTGRES_USER: outline
      POSTGRES_DB: outline
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - database-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "outline", "-d", "outline"]
      interval: 10s
    restart: unless-stopped

volumes:
  database-data:

Bring the stack up from /opt/outline (docker compose up -d), then follow docker compose logs -f outline; missing variables are listed at startup. Pinning a version instead of latest is the project's own advice, so upgrades happen when you choose.

Docker writes its own iptables rules for published ports and they are evaluated before ufw's, so a port published as plain 3000:3000 is reachable from outside whatever ufw says. The 127.0.0.1 prefix keeps it on loopback; confirm with ss -tlnp | grep 3000. Redis and PostgreSQL publish nothing at all.

TLS and the websocket proxy

Outline should always be served over HTTPS, and the proxy must forward websockets or documents load but cannot be edited. This nginx site follows the project's example, plus proxy_http_version 1.1, which nginx needs for the upgrade, and a body limit so uploads larger than nginx's 1 MB default get through.

# /etc/nginx/sites-available/outline
server {
    listen 80;
    listen [::]:80;
    server_name wiki.example.com;
    client_max_body_size 300m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
}

Enable the site, install certbot with its nginx plugin, and certbot --nginx -d wiki.example.com adds the 443 listener and certificate. With TLS ending at nginx, keep FORCE_HTTPS=true. Register the provider's callback, sign in yourself first, set the allowed domains, and only then ufw allow 443/tcp for everyone.

Uploads: local disk or S3

  • Local (FILE_STORAGE=local): files land in /var/lib/outline/data inside the container, so they must be mapped to the host or they vanish with it. The bind mount above, owned by uid 1001, is the documented way to do that. Set FILE_STORAGE_UPLOAD_MAX_SIZE in bytes and keep nginx's limit above it.
  • S3 (FILE_STORAGE=s3 plus the AWS_* variables): needs BlockPublicAcls off at bucket level, a CORS policy naming your URL, and an IAM policy covering object get, put, delete, the two ACL actions and bucket listing. Community-tested: Amazon S3, MinIO, DigitalOcean; Cloudflare R2 and Backblaze are listed as not working.
  • A MinIO server works, but put it on its own domain: on Outline's domain the browser forwards Outline's authorization header to it and uploads fail.

Scheduled jobs, backups and upgrades

  1. Cron. Trash cleanup, reminders and popularity scores depend on /api/cron.hourly and /api/cron.daily being called with ?token= set to UTILS_SECRET. Keep the token off process lists: a root-only script, /opt/outline/cron.sh, that reads just the two values it needs, URL=$(sed -n 's/^URL=//p' /opt/outline/.env) and UTILS_SECRET=$(sed -n 's/^UTILS_SECRET=//p' /opt/outline/.env), and pipes printf 'url = "%s/api/cron.%s?token=%s"\n' "$URL" "$JOB" "$UTILS_SECRET" into curl -fsS -K -; /etc/cron.d then runs JOB=hourly bash /opt/outline/cron.sh every hour and the same with JOB=daily once a day. The query string still lands in nginx's access log, so keep that log root-readable. Never source the .env: it is a Docker env file, not shell syntax, and a value such as a display name with spaces breaks bash.
  2. Database. The docs recommend daily backups at minimum, kept for a month. Create the target once with mkdir -m 700 /var/backups/outline, then from /opt/outline run docker compose exec -T postgres pg_dump -U outline -Fc outline > /var/backups/outline/db-$(date +%F).dump; -T means no TTY, so it works from cron. Written directly into a crontab line, each % must be escaped as \%.
  3. Files and secrets. Archive /opt/outline/data and .env into a root-only directory, then copy off the box. Without SECRET_KEY, every encrypted value in the database is unreadable.
  4. Upgrades. Back up, change OUTLINE_TAG, docker compose pull, docker compose up -d. Migrations run automatically on start (--no-migrate disables that) and are not always backwards compatible, so a rollback means restoring the dump.

Settings → Export also produces HTML, Markdown or JSON copies of the content, a useful second format but not a substitute for the database dump.

Licence terms

The community edition is under the Business Source License 1.1, not an open-source licence. Its Additional Use Grant reads: "You may make use of the Licensed Work, provided that you may not use the Licensed Work for a Document Service." The same file defines a Document Service as a commercial offering through which outside parties, meaning anyone other than your own employees and contractors, get to use Outline by setting up teams and documents they control.

An internal wiki for your company is inside the grant. Selling Outline workspaces to customers is not, and the hosting docs say such use automatically terminates your rights. Each version converts to Apache 2.0 on its Change Date or four years after its first public release, whichever comes first.

Which machine to run it on

The documented floor is small, so pick the box for the disk that holds uploads and dumps, and for recovery. A single-socket server from the dedicated server range has its own out-of-band console (iLO, iDRAC or a Supermicro BMC), which matters the day a ufw rule locks out SSH. Moonshot cartridges, one small server per chassis slot, also fit the documented floor, but apart from the m510 and m710x they offer power control only. Every line gives root, an unmetered port and free Linux images you reinstall from the control panel; a racked machine is handed over in about 30 minutes once payment is approved.

Wrong fit: a team that wants plain email-and-password accounts with no identity provider (BookStack has local logins), or anyone planning to sell Outline to other organisations as a hosted service.

Questions

Can Outline run without SSO or an identity provider?

Not with passwords: Outline has no native email-and-password login. The nearest option is the email magic link, which needs working SMTP and the email method enabled under Settings → Authentication. Users then receive a single-use, time-limited sign-in link.

Which PostgreSQL and Redis versions does it need?

The requirements page sets PostgreSQL v14 and Redis v4 as the floor. The Compose example on the Docker page uses the postgres:18 image. One Redis instance is the recommended setup for self-hosting.

What happens if SECRET_KEY is lost?

Encrypted data in the database becomes unreadable: access tokens, sessions and integrations are destroyed, though unencrypted content can be recovered with a reset script. Back up .env after every change to an encrypted store.

Do I have to use the https-portal container from the docs?

No. The docs offer it for automatic Let's Encrypt certificates, but any reverse proxy works if it forwards websockets. A host nginx in front of 127.0.0.1:3000 keeps the app port off the public interface.

Can the community edition use SAML?

No. SAML, including Okta and OneLogin setups, is listed for the licensed Business and Enterprise editions. On the community edition use OIDC, which most SAML-capable identity servers also speak.

Put the wiki on its own machine

Choose a line with an out-of-band console and the disk your uploads need, then follow the steps above.

See the machines

Related on Server Room

Other use cases