Skip to main content

Configure the Docker Compose deployment

Use the shared Norn configuration reference for application settings and the environment variable reference for the complete list. This page covers only how the compose file maps that configuration onto containers.

Where configuration lives

.env sits next to compose.yaml and is read in two ways. Compose expands ${…} in the compose file from it, and the API, worker, and the two one-shot containers receive the whole file as environment. Any NORN_* setting can therefore be added to .env and it takes effect on the next docker compose up -d.

The compose file reserves the settings that describe the deployment itself:

SettingOwned because
NORN_HTTP_ADDR, NORN_WORKER_HEALTH_ADDRthe listeners must bind every interface inside a container
NORN_HTTP_CLIENT_IP_HEADER, NORN_HTTP_TRUSTED_PROXIESthe trusted range is the compose network
NORN_POSTGRES_DSNassembled from POSTGRES_PASSWORD and the bundled service name
NORN_VALKEY_ADDR, NORN_VALKEY_PASSWORD, NORN_VALKEY_DBthe bundled service, on database 0
NORN_ASYNQ_ADDR, NORN_ASYNQ_PASSWORD, NORN_ASYNQ_DBthe same service, on database 1
NORN_STORAGE_BACKEND, NORN_STORAGE_ROOTthe attachment volume mounted into the API and worker
NORN_APP_ENV, NORN_INSTANCE_SELF_HOSTEDfixed for this deployment method

Setting one of these in .env has no effect. Change them the way the next section describes.

Override what the compose file owns

Compose merges compose.override.yaml from the same directory automatically. Keep every change there rather than editing compose.yaml, so that replacing compose.yaml during an upgrade does not discard the deployment's own decisions.

compose.override.yaml
services:
api:
environment:
NORN_HTTP_TRUSTED_PROXIES: 172.30.0.0/16,10.8.0.0/24

Two merge behaviours are worth knowing. Mappings such as environment merge key by key, so an override replaces one value and leaves the rest. Sequences replace wholesale, and !reset clears an inherited key entirely.

External PostgreSQL

Stop the bundled service and point Norn at the managed one. The migrate container waits for the bundled database, so its dependency has to be cleared as well:

compose.override.yaml
services:
postgres:
profiles: ["unused"]

migrate:
environment: &external-postgres
NORN_POSTGRES_DSN: postgres://norn:<password>@db.example.net:5432/norn?sslmode=require
depends_on: !reset {}

seed:
environment: *external-postgres

api:
environment: *external-postgres

worker:
environment: *external-postgres

Use a dedicated database and a role with permission to apply Norn's embedded migrations. Require TLS when the provider supports it. A password inside a connection string must be URL-safe or percent-encoded. Do not connect two Norn instances to the same database.

Removing the postgres service does not remove its volume. Delete norn_postgres-data only after the migration to the external database is verified.

External Valkey

compose.override.yaml
services:
valkey:
profiles: ["unused"]

seed:
environment: &external-valkey
NORN_VALKEY_ADDR: valkey.example.net:6379
NORN_VALKEY_PASSWORD: <password>
NORN_ASYNQ_ADDR: valkey.example.net:6379
NORN_ASYNQ_PASSWORD: <password>
depends_on:
valkey: !reset null

api:
environment: *external-valkey

worker:
environment: *external-valkey

Norn connects to Valkey without TLS, so an external service must be on a trusted network. It must also satisfy Norn's Valkey requirements: eviction disabled, and two database indexes, because sessions and the job queue are kept apart.

Object storage

The compose deployment stores attachments on a Docker volume, mounted into the API and the worker at /var/lib/norn/blobs. Bytes travel through the API under /v1/blobs, so there is no second hostname, no browser CORS policy, and no object-storage credentials to hold.

Use S3-compatible storage instead when the host's disk is not where attachments belong:

compose.override.yaml
services:
api:
environment: &object-storage
NORN_STORAGE_BACKEND: s3
NORN_STORAGE_ENDPOINT: https://storage.norn.example.com
NORN_STORAGE_REGION: eu-west-1
NORN_STORAGE_BUCKET: norn-production
NORN_STORAGE_ACCESS_KEY_ID: <access-key-id>
NORN_STORAGE_SECRET_ACCESS_KEY: <secret-access-key>
NORN_STORAGE_USE_PATH_STYLE: "false"

worker:
environment: *object-storage

With S3, the browser transfers attachment bytes directly to the endpoint. The endpoint must be browser-reachable, must serve a valid certificate, and must be on a different host from NORN_APP_BASE_URL — the API refuses to start when they share one, because objects would then be served by an origin that holds the session cookie. The bucket must already exist, and it needs the object-storage CORS policy.

Switching backends does not move existing attachments. Migrate the contents of the volume into the bucket first, keeping each object's path.

Public routing

Caddy implements Norn's public routing contract. It terminates TLS for NORN_APP_BASE_URL, sends /v1, /mcp, /oauth, and /.well-known to the API and everything else to the dashboard, streams /v1/workspaces/<workspace>/events without buffering, and compresses only the content types that are safe to compress.

If another system already owns routing, take Caddy out and publish the two upstreams on the loopback interface:

compose.override.yaml
services:
caddy:
profiles: ["unused"]

api:
ports:
- "127.0.0.1:8080:8080"

web:
ports:
- "127.0.0.1:3000:3000"

The external proxy then has to satisfy the routing contract itself. deploy/nginx/norn.conf in the Norn repository is a worked nginx example of the same path split, including the unbuffered Server-Sent Events location.

Certificates

Caddy requests a certificate for NORN_APP_BASE_URL on first start and renews it automatically. Issuance needs port 80 and port 443 reachable from the internet and a DNS record that already points at the host. Certificates and account keys live in the norn_caddy-data volume; keeping that volume across upgrades avoids re-issuing on every restart.

An instance on a private network can use a plain-http origin instead. Set NORN_APP_BASE_URL to an http:// URL and turn off the secure-cookie requirement, which the API otherwise enforces:

.env
NORN_APP_BASE_URL=http://norn.internal
NORN_SESSION_SECURE=false

Session cookies then travel unencrypted. Use this only on a network where that is acceptable.

Scaling

The API and the dashboard are stateless and can run more than one container:

docker compose up -d --scale api=3 --scale web=3

Caddy resolves api and web through Docker's DNS, which answers with every container in the service. Connection reuse means the spread is approximate rather than balanced request by request.

The worker stays at one container. It runs the job scheduler in-process without leader election, so a second worker double-fires every scheduled job. Scaling it is not supported.

Bundled PostgreSQL and Valkey are single containers on one host. Scaling the application does not make the data layer redundant.