Deployment
Schleuse is built to run as a single-container sidecar. The minimum stack is: one IdP container, one database (SQLite on a volume or a real Postgres), and a reverse proxy handling TLS.
Production checklist
- [ ]
NUXT_ISSUER,NUXT_APP_URL,NUXT_RP_ORIGINpoint to the real public domain (withhttps://) - [ ]
NODE_ENV=production→ enables secure cookies and HSTS - [ ]
NUXT_TRUST_PROXY=true— if behind nginx/Caddy/ALB/Traefik - [ ]
/app/datavolume mounted — persists auto-generated JWT keys (and SQLite DB if used) - [ ]
NUXT_SMTP_*— real mail provider (optional; without it, email verification and 2FA are disabled) - [ ] TLS on the reverse proxy
- [ ] Database backups:
pg_dump(Postgres) or file-level copy ofschleuse.db+ thedata/keys (jwt-*.pem,jwt-secret.txt,totp-encryption-key.txt) - [ ]
NUXT_RATE_LIMIT_*tuned for your expected traffic - [ ] After the first boot: verify mail from Admin → Email & SMTP — connection probe, template preview, and a test send that reports the SMTP server's own error if it fails
- [ ] Confirm that page reports the hop as encrypted, then set
NUXT_SMTP_REQUIRE_TLS=trueto make it mandatory (see Security → Encrypt the mail hop)
Boot-time validation. The app fails fast on contradictory config: it exits if
NUXT_ISSUERdoesn't end with/oidcor ifNUXT_COOKIE_SECURE=trueis paired with anhttp://NUXT_APP_URL, and warns ifNUXT_APP_URLandNUXT_RP_ORIGINhave different origins. If the container exits immediately on start, check the logs for alevel: fatalline naming the variable. See Configuration → Startup validation.
Pull + run
The recommended path is the pre-built image from the GitLab container registry — no source tree, no build step, reproducible across hosts:
docker compose pull
docker compose up -dWhich tag to pull
| Tag | Points at | Use it for |
|---|---|---|
:v1.2.0 (any semver) | one immutable release | production — a pull only changes anything when you change the tag |
:latest | the most recent semver release | convenience; moves under you on every release |
:next | the tip of main, rebuilt on every push | trying an unreleased fix, staging |
:main-<short-sha> | one specific main commit, immutable | pinning or rolling back a pre-release build |
:next is built only after the test suite passes, but it is not a release: no changelog entry, no migration notes, and it can change several times a day. Nothing but a release pipeline ever moves :latest, so a main build can never be mistaken for a release.
Pin a semver tag in production so a pull is intentional.
Reference compose.yml
A minimal production stack — pinned image, Postgres with a health-gated start, a persisted data volume. TLS is handled by the reverse proxy (next section), so the app only needs to be reachable on the internal network.
services:
app:
image: registry.gitlab.com/meowww_dev/schleuse:v0.5.0 # pin a real tag
restart: unless-stopped
env_file: .env # NUXT_ISSUER, NUXT_APP_URL, NUXT_RP_ORIGIN, secrets, SMTP…
environment:
NODE_ENV: production # secure cookies + HSTS
NUXT_DATABASE_URL: postgresql://schleuse_user:schleuse_password@postgres:5432/schleuse
NUXT_TRUST_PROXY: "true" # honour X-Forwarded-* from the proxy
volumes:
- app_data:/app/data # auto-generated JWT keys (+ SQLite DB if used)
depends_on:
postgres:
condition: service_healthy # app starts only after the DB is ready
# expose to the reverse proxy on the same Docker network; no host port needed
# ports: ["127.0.0.1:5000:3000"] # …or bind to loopback if the proxy is on the host
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: schleuse_user
POSTGRES_PASSWORD: schleuse_password
POSTGRES_DB: schleuse
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U schleuse_user -d schleuse"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
app_data:For SQLite instead of Postgres: drop the postgres service + depends_on, set NUXT_DATABASE_URL=sqlite:/app/data/schleuse.db, and keep the app_data volume (the DB lives there alongside the keys). The repository's docker-compose.yml is the dev counterpart (it adds Mailcatcher and builds from source).
Building from source (contributors)
If you're iterating on the code itself:
pnpm build
node .output/server/index.mjsThe .output/ directory is self-contained — no node_modules needed at runtime. Or build the image locally instead of pulling:
docker compose up --buildReverse proxy — Caddy
Simplest setup, automatic TLS via Let's Encrypt:
auth.example.com {
reverse_proxy schleuse:3000
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
}
}Reverse proxy — nginx
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://schleuse:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Remember to set NUXT_TRUST_PROXY=true so the app honours X-Forwarded-For for rate limiting. If it sees forwarded headers while NUXT_TRUST_PROXY is false, the app logs a one-time warning at startup-of-traffic that client-IP and secure-cookie handling may be wrong.
Reverse proxy — Traefik
Label-driven, on the same Docker network as the app. Traefik sets X-Forwarded-* automatically; you still need NUXT_TRUST_PROXY=true on the app.
app:
# …as above…
labels:
- "traefik.enable=true"
- "traefik.http.routers.schleuse.rule=Host(`auth.example.com`)"
- "traefik.http.routers.schleuse.entrypoints=websecure"
- "traefik.http.routers.schleuse.tls.certresolver=le"
- "traefik.http.services.schleuse.loadbalancer.server.port=3000"TLS termination
Terminate TLS at the reverse proxy (Caddy/nginx/Traefik/ALB); the app speaks plain HTTP on :3000 on the internal network only — never expose :3000 publicly. Two consequences follow from "HTTPS at the edge, HTTP inside":
- Set
NUXT_TRUST_PROXY=truesoX-Forwarded-Proto/-Forare honoured (correct client IPs for rate limiting, correct scheme awareness). - Keep
NUXT_COOKIE_SECURE=true(the default underNODE_ENV=production) — cookies are then only sent over HTTPS, which is what the browser sees. The app refuses to start ifNUXT_COOKIE_SECURE=trueis combined with anhttp://NUXT_APP_URL(see startup validation), soNUXT_APP_URL/NUXT_ISSUER/NUXT_RP_ORIGINmust all usehttps://.
Running as a service (systemd)
To start the stack on boot and restart it on failure, wrap docker compose in a unit. Compose's depends_on: condition: service_healthy already orders the DB before the app, so systemd only needs to manage the stack as a whole:
# /etc/systemd/system/schleuse.service
[Unit]
Description=Schleuse IdP
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/schleuse # where your compose.yml + .env live
ExecStart=/usr/bin/docker compose up -d --pull always
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now schleuseVolumes
Schleuse writes to a single directory, /app/data, containing:
jwt-secret.txt— HMAC secret for session JWTs (auto-generated on first start)jwt-private-key.pem— RSA 2048 private key for OIDC ID-token signing (auto-generated)totp-encryption-key.txt— key encrypting authenticator-app (TOTP) secrets at rest (auto-generated)schleuse.db— the SQLite database, ifNUXT_DATABASE_URL=sqlite:/app/data/schleuse.db
Mount it as a named volume. Backups of this directory plus your Postgres dumps (if used) are the complete state of the IdP. For the full procedure, see the Backup & restore runbook.
Secret rotation
All secrets are env-only or files under /app/data; rotation is "change the value and restart". Per secret:
| Secret | Rotate by | Impact |
|---|---|---|
NUXT_JWT_SECRET / data/jwt-secret.txt | set a new ≥32-char value (or delete the file to auto-generate) and restart | invalidates all session cookies — everyone must log in again |
NUXT_JWT_PRIVATE_KEY / data/jwt-private-key.pem | replace the PEM and restart — or use the no-downtime procedure below | rotates the OIDC signing key (new kid). A plain swap makes in-flight ID/JWT-access tokens fail verification; the no-downtime procedure avoids that. Either way every derived confidential-client secret changes (sha256(key + clientId)), so update each RP's secret. |
| One confidential client's secret | set/bump that client's secretVersion in NUXT_OIDC_CLIENTS_JSON and restart | rotates only that client's client_secret (no effect on other clients or the signing key). Re-issue just that RP's secret. See Per-client secret rotation. |
| All confidential client secrets | rotate NUXT_JWT_PRIVATE_KEY (they're all derived from it) | re-issue every RP's client_secret |
NUXT_METRICS_TOKEN | set a new ≥32-char value and restart | old scrape token stops working; update Prometheus |
NUXT_ADMIN_API_TOKENS_JSON | add a second { id, token } entry, deploy, move the importer over, then remove the first | none — both tokens are accepted while the array holds both, so no coordinated restart. Removing the var entirely disables the provisioning endpoints (404), which is the right end state after a migration. |
NUXT_TOTP_ENCRYPTION_KEY / data/totp-encryption-key.txt | set a new value (or delete the file to auto-generate) and restart | existing authenticator-app (TOTP) secrets become undecryptable — affected users must re-enrol TOTP. Independent of NUXT_JWT_SECRET, so a force-logout rotation does not trigger this. |
| Postgres password | change in Postgres + NUXT_DATABASE_URL and restart | — |
Priority order for keys is env var > persisted file > auto-generate, so an explicit NUXT_JWT_* always wins over the file in /app/data.
Key rotation without downtime
Rotating the OIDC signing key without NUXT_JWT_PRIVATE_KEY_PREVIOUS drops the old kid from the JWKS, so every token a relying party still holds fails verification until it expires. To rotate without that gap, keep the old key in the JWKS (verify-only) during a transition window:
- Generate a new RSA key.
- Swap: set
NUXT_JWT_PRIVATE_KEY_PREVIOUSto the currentNUXT_JWT_PRIVATE_KEY, andNUXT_JWT_PRIVATE_KEYto the new key. Restart. New tokens are signed with the new key; both public keys are published at/.well-known/jwks.json(and/oidc/jwks), so tokens issued under the old key keep verifying. - Finish: after the transition window — at least the ID/access-token TTL (1 hour) so all old tokens have expired — remove
NUXT_JWT_PRIVATE_KEY_PREVIOUSand restart. The old key leaves the JWKS.
The previous key is published with key_ops: ["verify"], so it is never used to sign — only to verify.
Confidential clients
Confidential client secrets are derived sha256(NUXT_JWT_PRIVATE_KEY + clientId), so changing the active key also changes every confidential client's secret. Token verification is downtime-free, but confidential-client authentication breaks until you redistribute the new secrets. Public clients are unaffected.
Per-client secret rotation
To roll one confidential client's secret (e.g. it leaked) without touching any other client or the signing key, add an optional secretVersion to that client in NUXT_OIDC_CLIENTS_JSON and restart. The secret becomes sha256(NUXT_JWT_PRIVATE_KEY + clientId + secretVersion) — only that client changes; everyone else keeps their existing secret.
{ "clientId": "my-app", "public": false, "secretVersion": "2" }Bump the value again ("3", a date, any non-empty string) to rotate again. Omit the field for the legacy sha256(key + clientId) (the default, unchanged).
Or set an explicit secret. For reproducible multi-app deploys, give the client an explicit clientSecret (≥16 chars) in NUXT_OIDC_CLIENTS_JSON — it's used verbatim for both OIDC and the app API, so you configure it once in your env/compose and every app authenticates without copying a derived value:
{ "clientId": "my-app", "public": false, "clientSecret": "<a long random value>" }It takes precedence over the derived secret (rotate by changing the string). The value is masked in GET /api/admin/config and the configurator export (like every other secret), so keep it in your own config source; reveal it on demand from the admin Integration page.
Compute the new secret to hand to the relying party — same derivation the IdP uses (sha256 of the trimmed PEM + clientId + secretVersion, concatenated, no separators):
node -e 'const{createHash}=require("crypto"),fs=require("fs");\
const k=fs.readFileSync("data/jwt-private-key.pem","utf8").trim();\
console.log(createHash("sha256").update(k).update("my-app").update("2").digest("hex"))'(When NUXT_JWT_PRIVATE_KEY is set via env instead of the file, use that value in place of the file read.)
Upgrading
- Review the CHANGELOG for the target version — note new required env vars or behaviour changes before pulling.
- Back up first — Postgres dump (or the SQLite file) plus
/app/data(keys). Migrations are forward-only. - Bump the pinned tag, then
docker compose pull && docker compose up -d— migrations run automatically at startup. - Check
/api/ready— expect{ "status": "ready", "database": "connected", "oidcConfigured": true }, and confirmversionmatches the new tag.
Migrations are forward-only — rolling back to an older image after a migration has applied isn't supported, which is why step 2 (backup) matters before crossing minor versions. Pin exact tags in production so every upgrade is intentional; don't run :latest.
Pre-flight: duplicate email addresses
Schleuse stores email addresses in canonical form — trimmed and lowercased — so A@example.com and a@example.com are one account, not two. A migration canonicalises existing rows on first start after the upgrade.
If your database predates that and holds two accounts whose addresses differ only by case or whitespace, they cannot both survive. The migration will not guess which one to keep: it aborts, and because migrations run at startup the container will not come up. Check before you pull.
SELECT lower(btrim(email)) AS canonical,
count(*) AS accounts,
string_agg(email, ', ') AS spellings
FROM users
GROUP BY 1
HAVING count(*) > 1;SELECT lower(trim(email)) AS canonical,
count(*) AS accounts,
group_concat(email, ', ') AS spellings
FROM users
GROUP BY 1
HAVING count(*) > 1;No rows returned — nothing to do; the upgrade canonicalises silently.
Rows returned — each one is two or more real accounts sharing a mailbox. Decide which survives, then either delete the others (DELETE /api/admin/users/:id, which also removes their credentials and tokens) or give them a different address. Only you can say which account is the real one, which is why this is not automated. Re-run the query until it is empty, then upgrade.
If you upgrade without checking, the container logs a fatal error naming every colliding address and id, and nothing is written — resolve the duplicates and start it again.
Security upgrades
Releases that carry dependency security fixes need nothing beyond the normal docker compose pull && docker compose up -d above — no config change, no migration of your own, and no cache purge. Schleuse serves every page uncached (it ships no cache, swr, or isr route rules), so advisories about stale or cross-user cached payloads in the underlying framework have nothing to purge here even when you run a CDN in front of the IdP.
If you also run the docs site or build the image yourself, rebuild from the tagged source so the fixed dependencies are actually compiled in — pulling the published image already includes them.
Health checks
| Endpoint | Purpose |
|---|---|
GET /api/health | Liveness — returns {status: "ok"} unconditionally |
GET /api/ready | Readiness — verifies DB connection + OIDC config |
The built-in Dockerfile HEALTHCHECK polls /api/ready every 15s.
Logs
Structured JSON via pino. Every line includes a requestId that correlates to the X-Request-Id response header (propagated if the client sends one). Quiet paths (/_nuxt/, /__nuxt, /api/health) log at debug level.
Fields like password, token, secret, authorization, cookie are auto-redacted before they're emitted.
Stream logs to your aggregator of choice (Loki, CloudWatch, Datadog) — no special adapter needed, stdout is the interface.
Metrics (Prometheus)
Set NUXT_METRICS_TOKEN (≥ 32 chars) to enable GET /metrics. Unset, the endpoint returns 404. Each scrape must send the token as a bearer credential:
# prometheus.yml
scrape_configs:
- job_name: schleuse
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/schleuse-metrics-token # or `credentials: <token>`
static_configs:
- targets: ['schleuse:3000']Exposed series: schleuse_logins_total{result} (success / failure — covers password and second-factor / passkey login outcomes), schleuse_registrations_total, schleuse_2fa_challenges_total, schleuse_rate_limit_hits_total{endpoint}, schleuse_emails_total{result} (sent / failed — failed increments only after all retry attempts are exhausted), schleuse_suspicious_logins_total (logins flagged as from a new IP or device), schleuse_oidc_tokens_issued_total{grant_type} (token-endpoint issuance by authorization_code / refresh_token), schleuse_force_logout_total (global force-logout actions), http_request_duration_seconds{method,status_code,route} (the route label is the matched route template or an allow-listed group — bounded, never a raw id-bearing path), plus standard nodejs_* / process_* metrics. Counters are in-memory and reset on restart.
Exposure: even with the token, do not publish /metrics on the public reverse proxy — scrape it from the private/internal network (defence in depth). The token is constant-time compared and never written to logs.
Platform-as-a-Service
For Coolify-style platforms, the repository ships two ready-to-import compose templates. Any host that runs an OCI image works — the only persistent state is /app/data and your database.
| Template | Shape | Pick it when |
|---|---|---|
docker-compose.coolify.yml | App + its own postgres:16-alpine, two volumes | You want pg_dump-style backups, or expect to outgrow one container |
docker-compose.coolify-sqlite.yml | App only, one volume, sqlite:/app/data/schleuse.db | Small instance, no separate database to run or back up |
The two files are otherwise identical — same magic-variable block, same OIDC client example, same healthcheck. Switching later means moving the data, not rewriting the deployment.
The SQLite volume holds everything
In the Postgres variant /app/data carries only the auto-generated keys. In the SQLite variant it carries the database as well — losing the volume loses the users, the audit log, every TOTP enrolment and every passkey at once. A tar or docker cp of a live WAL-mode database is not a safe backup; see Backup & restore for the online-backup commands and the restore side, which has to clear stale -wal / -shm files.
Coolify magic variables: declare with the port, reference without it
The bare SERVICE_FQDN_APP_3000 entry provisions the domain and routes it to container port 3000 — keep the port there. Every value must use the port-less form (${SERVICE_URL_APP}, ${SERVICE_FQDN_APP}), because ${SERVICE_URL_APP_3000} expands to https://your-domain:3000 and that port is internal (coolify#8638).
It passes startup validation — it is a valid URL — so the failure is silent and only shows at the edges: emailed invite, reset and verification links point at an unreachable port, /.well-known/openid-configuration advertises an issuer no relying party will match, and every passkey fails the exact-origin check. NUXT_RP_ID is the deliberate exception in the other direction: a WebAuthn RP ID is a bare domain, so it takes SERVICE_FQDN_APP, never a URL.
After deploying, confirm it took: curl -s https://your-domain/.well-known/openid-configuration | grep issuer must show no :3000.
Related guides
- Configuration — every env var, plus boot-time startup validation.
- Security — cookie/session model, OIDC token handling, metrics auth, logout.
- OIDC provider — client config, JWT access tokens, back-channel logout.
Known production gaps
Known gaps in the current release:
- Email delivery retry — one SMTP attempt, lost on transient failure.