Self-hosting
A Norn instance is three long-running processes and three backing services. Everything below is what the software actually requires — there is no separate self-hosted edition.
What you need
| Component | Purpose |
|---|---|
| PostgreSQL | Primary datastore. Migrations are embedded in the binary. |
| Valkey | Sessions, caching, rate limits, the job queue, and Casbin policy synchronisation. |
| S3-compatible object storage | Attachments and import payloads. |
| SMTP relay | Verification, password reset, and invitation mail. |
Valkey must run with noeviction. Cache misses are tolerated, but sessions, rate limits, jobs, and
policy synchronisation fail closed — silently evicting those keys will lock people out rather than
degrade.
The docker-compose.yml in the repository is a local development stack only, never a deployment
artifact. It exists so make dev can bring up PostgreSQL, Valkey, and Garage on a laptop. Do not
deploy from it.
The processes
norn migrate # apply embedded migrations, then exit
norn seed # idempotently seed the Casbin policy and development data
norn serve # the HTTP API on :8080
norn worker # background jobs
node build # the SvelteKit dashboard
norn migrate runs the migrations embedded in the binary, so there is no separate migration image or
goose CLI to install. Run it before serve on every upgrade.
norn seed reconciles the Casbin authorisation policy. A freshly migrated database has no policy
rows, and serve does not reconcile them on boot — every protected route will return 403 until the
seed has run at least once.
norn jobs is the operator surface for the queue. Archived tasks are the dead-letter queue.
Configuration
Configuration precedence is environment variables (prefixed NORN_) over an optional --config file
over built-in defaults. Nested keys flatten with underscores, so postgres.dsn is
NORN_POSTGRES_DSN.
- Core
- Storage and mail
- Schedules
NORN_APP_ENV=production
NORN_APP_LOG_LEVEL=info
NORN_APP_BASE_URL=https://norn.example.com
NORN_POSTGRES_DSN=postgres://norn:secret@127.0.0.1:5432/norn?sslmode=require
NORN_VALKEY_ADDR=127.0.0.1:6379
NORN_ASYNQ_ADDR=127.0.0.1:6379
NORN_SECURITY_ENCRYPTION_KEY=
NORN_STORAGE_BACKEND=s3
NORN_STORAGE_ENDPOINT=https://s3.example.com
NORN_STORAGE_BUCKET=norn
NORN_STORAGE_ACCESS_KEY_ID=
NORN_STORAGE_SECRET_ACCESS_KEY=
NORN_SMTP_HOST=smtp.example.com
NORN_SMTP_PORT=587
NORN_SMTP_FROM_ADDRESS=no-reply@norn.example.com
NORN_CYCLES_GENERATION_SCHEDULE=5 0 * * *
NORN_SAML_CERTIFICATE_SWEEP_SCHEDULE=0 8 * * *
NORN_WEBHOOKS_FAN_OUT_SCHEDULE=* * * * *
NORN_WEBHOOKS_SWEEP_SCHEDULE=0 5 * * *
NORN_IMPORTS_RESCUE_SCHEDULE=* * * * *
The reverse proxy contract
The Node server and /v1 must share one origin. The session cookie is host-only
(Domain="", SameSite=Lax) and the Go server ships no CORS middleware, so a proxy putting both on
one hostname is a requirement, not a preference.
/v1 is never routed through SvelteKit. Kit's CSRF origin check would reject the SAML ACS
cross-site POST, and an SSE stream or a multi-megabyte upload has no business on the Node event
loop.
Node
| Variable | Value | Why |
|---|---|---|
ORIGIN | https://norn.example.com | Without it, adapter-node computes the wrong URL and Kit's CSRF check returns 403 on every form action — in production only, since the check is disabled in dev. |
ADDRESS_HEADER | x-forwarded-for | So Kit reads the real client address. |
XFF_DEPTH | number of proxies in front | Must match reality. |
BODY_SIZE_LIMIT | at least NORN_HTTP_MAX_REQUEST_BYTES | adapter-node defaults to 512 KiB against Go's 4 MiB. |
INTERNAL_API_ORIGIN | http://127.0.0.1:8080 | Keeps server-side loads off the public proxy. |
Go
NORN_HTTP_CLIENT_IP_HEADER=X-Forwarded-For
NORN_HTTP_TRUSTED_PROXIES=10.0.0.0/8
Set these two together. The server refuses to boot otherwise, because trusting the header without an allow-list makes the per-address sign-in throttle forgeable.
Proxy
- Enable gzip or brotli on
text/htmlandapplication/json.adapter-nodeprecompresses static assets but never compresses SSR output. - For
/v1/workspaces/*/events, setproxy_read_timeout 0and turn buffering off — it is a Server-Sent Events stream. - Set
client_max_body_sizeto at least the attachment limit.
location /v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
location /v1/workspaces/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 0;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
NORN_SECURITY_ENCRYPTION_KEY protects stored credentials such as SSO client secrets. Generate it
once, back it up, and never rotate it casually — losing it means losing the ability to decrypt
everything that was encrypted under it.
Upgrading
- Take a database backup.
- Deploy the new binary.
- Run
norn migrate. - Run
norn seedso any newly introduced resource gets its policy rows. - Restart
norn serveandnorn worker.