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:
| Setting | Owned because |
|---|---|
NORN_HTTP_ADDR, NORN_WORKER_HEALTH_ADDR | the listeners must bind every interface inside a container |
NORN_HTTP_CLIENT_IP_HEADER, NORN_HTTP_TRUSTED_PROXIES | the trusted range is the compose network |
NORN_POSTGRES_DSN | assembled from POSTGRES_PASSWORD and the bundled service name |
NORN_VALKEY_ADDR, NORN_VALKEY_PASSWORD, NORN_VALKEY_DB | the bundled service, on database 0 |
NORN_ASYNQ_ADDR, NORN_ASYNQ_PASSWORD, NORN_ASYNQ_DB | the same service, on database 1 |
NORN_STORAGE_BACKEND, NORN_STORAGE_ROOT | the attachment volume mounted into the API and worker |
NORN_APP_ENV, NORN_INSTANCE_SELF_HOSTED | fixed 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.
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:
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
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:
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:
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:
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.