Skip to content

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_ORIGIN point to the real public domain (with https://)
  • [ ] NODE_ENV=production → enables secure cookies and HSTS
  • [ ] NUXT_TRUST_PROXY=true — if behind nginx/Caddy/ALB/Traefik
  • [ ] /app/data volume 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 of schleuse.db + the data/ 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=true to make it mandatory (see Security → Encrypt the mail hop)

Boot-time validation. The app fails fast on contradictory config: it exits if NUXT_ISSUER doesn't end with /oidc or if NUXT_COOKIE_SECURE=true is paired with an http:// NUXT_APP_URL, and warns if NUXT_APP_URL and NUXT_RP_ORIGIN have different origins. If the container exits immediately on start, check the logs for a level: fatal line 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:

bash
docker compose pull
docker compose up -d

Which tag to pull

TagPoints atUse it for
:v1.2.0 (any semver)one immutable releaseproduction — a pull only changes anything when you change the tag
:latestthe most recent semver releaseconvenience; moves under you on every release
:nextthe tip of main, rebuilt on every pushtrying an unreleased fix, staging
:main-<short-sha>one specific main commit, immutablepinning 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.

yaml
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:

bash
pnpm build
node .output/server/index.mjs

The .output/ directory is self-contained — no node_modules needed at runtime. Or build the image locally instead of pulling:

bash
docker compose up --build

Reverse proxy — Caddy

Simplest setup, automatic TLS via Let's Encrypt:

caddy
auth.example.com {
  reverse_proxy schleuse:3000
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options nosniff
  }
}

Reverse proxy — nginx

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.

yaml
  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=true so X-Forwarded-Proto/-For are honoured (correct client IPs for rate limiting, correct scheme awareness).
  • Keep NUXT_COOKIE_SECURE=true (the default under NODE_ENV=production) — cookies are then only sent over HTTPS, which is what the browser sees. The app refuses to start if NUXT_COOKIE_SECURE=true is combined with an http://NUXT_APP_URL (see startup validation), so NUXT_APP_URL/NUXT_ISSUER/NUXT_RP_ORIGIN must all use https://.

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:

ini
# /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.target
bash
sudo systemctl enable --now schleuse

Volumes

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, if NUXT_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:

SecretRotate byImpact
NUXT_JWT_SECRET / data/jwt-secret.txtset a new ≥32-char value (or delete the file to auto-generate) and restartinvalidates all session cookies — everyone must log in again
NUXT_JWT_PRIVATE_KEY / data/jwt-private-key.pemreplace the PEM and restart — or use the no-downtime procedure belowrotates 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 secretset/bump that client's secretVersion in NUXT_OIDC_CLIENTS_JSON and restartrotates 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 secretsrotate NUXT_JWT_PRIVATE_KEY (they're all derived from it)re-issue every RP's client_secret
NUXT_METRICS_TOKENset a new ≥32-char value and restartold scrape token stops working; update Prometheus
NUXT_ADMIN_API_TOKENS_JSONadd a second { id, token } entry, deploy, move the importer over, then remove the firstnone — 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.txtset a new value (or delete the file to auto-generate) and restartexisting 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 passwordchange 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:

  1. Generate a new RSA key.
  2. Swap: set NUXT_JWT_PRIVATE_KEY_PREVIOUS to the currentNUXT_JWT_PRIVATE_KEY, and NUXT_JWT_PRIVATE_KEY to 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.
  3. 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_PREVIOUS and 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.

json
{ "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:

json
{ "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):

bash
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

  1. Review the CHANGELOG for the target version — note new required env vars or behaviour changes before pulling.
  2. Back up first — Postgres dump (or the SQLite file) plus /app/data (keys). Migrations are forward-only.
  3. Bump the pinned tag, then docker compose pull && docker compose up -d — migrations run automatically at startup.
  4. Check /api/ready — expect { "status": "ready", "database": "connected", "oidcConfigured": true }, and confirm version matches 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.

sql
SELECT lower(btrim(email)) AS canonical,
       count(*)            AS accounts,
       string_agg(email, ', ') AS spellings
FROM users
GROUP BY 1
HAVING count(*) > 1;
sql
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

EndpointPurpose
GET /api/healthLiveness — returns {status: "ok"} unconditionally
GET /api/readyReadiness — 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:

yaml
# 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 / failedfailed 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.

TemplateShapePick it when
docker-compose.coolify.ymlApp + its own postgres:16-alpine, two volumesYou want pg_dump-style backups, or expect to outgrow one container
docker-compose.coolify-sqlite.ymlApp only, one volume, sqlite:/app/data/schleuse.dbSmall 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.

Known production gaps

Known gaps in the current release:

  • Email delivery retry — one SMTP attempt, lost on transient failure.

A small but polished sidecar identity provider.