Files
proxy/DEPLOYMENT.md
T

13 KiB

Deployment Guide — theta42/proxy

The proxy is an OpenID Connect-protected reverse proxy (OpenResty front + Node management app + Redis) that is both an OIDC client of an SSO Manager and a direct LDAP client for user lookups. Two deployment methods:

  1. Docker — a single all-in-one image bundling OpenResty + the app + Redis (docker compose up).
  2. Bare metalops/install.sh on Debian/Ubuntu (installs Node.js, OpenResty, Lua modules, Redis, and a systemd unit).

How configuration works

The app loads configuration via @simpleworkjs/conf, which deep-merges, in order:

  1. conf/base.js (committed, generic defaults)
  2. conf/<NODE_ENV>.js (optional)
  3. conf/secrets.js (gitignored — secrets + per-deployment values)
  4. app_* environment variables — the highest-precedence layer

Any env var whose name starts with app_ overrides the merged config. The rest of the name is split on double-underscore (__) into a nested path. Values are JSON.parse-coerced when possible and kept as raw strings otherwise.

Env var Sets Notes
app_oidc__issuer conf.oidc.issuer browser-facing SSO URL
app_oidc__authorizationEndpoint conf.oidc.authorizationEndpoint browser-facing
app_oidc__tokenEndpoint conf.oidc.tokenEndpoint server-to-server; can be internal
app_oidc__userinfoEndpoint conf.oidc.userinfoEndpoint server-to-server; can be internal
app_oidc__endSessionEndpoint conf.oidc.endSessionEndpoint browser-facing
app_oidc__clientId / app_oidc__clientSecret OIDC client creds register in the SSO first
app_oidc__redirectUri conf.oidc.redirectUri must match the SSO client exactly
app_oidc__enabled conf.oidc.enabled boolean
app_ldap__url conf.ldap.url ldaps://…:636 or ldap://…:389
app_ldap__bindDN / app_ldap__bindPassword LDAP service account don't reuse the admin DN
app_ldap__searchBase / app_ldap__userFilter user search
app_ldap__tlsOptions__rejectUnauthorized conf.ldap.tlsOptions.rejectUnauthorized false for self-signed LDAPS
app_ldap__tlsOptions__ca conf.ldap.tlsOptions.ca path to a CA cert for strict trust
app_auth__adminUsers conf.auth.adminUsers local anti-lockout admin (uid)
app_auth__adminGroups conf.auth.adminGroups SSO/LDAP groups that are global admin (JSON array)
app_auth__localAdminPass conf.auth.localAdminPass initial password for the local anti-lockout admin (used once, on first creation only — defaults to the username itself if unset)
app_redis__prefix conf.redis.prefix default proxy_

See docs/docker.md for a shorter, container-focused version of this reference.

Requires @simpleworkjs/conf >= 1.1.0. The Docker image will not honor app_* env vars on 1.0.0. The lock is already on ^1.1.0; if you regenerate it:

cd nodejs && npm install @simpleworkjs/conf@^1.1.0

Method 1: Docker (all-in-one)

The image (Dockerfile) bundles OpenResty + the app + Redis in one container, mirroring the bare-metal ops/install.sh layout. docker-entrypoint.sh: generates the fallback SSL cert, parameterizes the OpenResty resolver/ set_real_ip_from directives, starts Redis + the node app, and execs OpenResty in the foreground under dumb-init.

Setup

The bundled docker-compose.yml reads the OIDC + LDAP + auth wiring from a bind-mounted ./config/proxy-secrets.js (not from app_* env). Copy the example, fill in your secrets, then build + start:

mkdir -p config && chmod 700 config
cp secrets.js.example config/proxy-secrets.js
$EDITOR config/proxy-secrets.js     # set oidc.clientId/clientSecret, ldap.bindPassword, ...
docker compose up -d --build

docker-entrypoint.sh sets CONF_SECRETS=/config/proxy-secrets.js so @simpleworkjs/conf reads it directly. No app_* env is passed — app_* env would override the file (env beats secrets.js in @simpleworkjs/conf), so the file is kept authoritative. RESOLVER / REAL_IP_FROM / NODE_ENV / NODE_PORT are OpenResty-runtime / process env, not app_* config, so they stay in the compose.

Running the unified theta-env stack? Its setup.sh generates ./config/proxy-secrets.js (+ ./config/sso-secrets.js) for you and registers the OAuth client with the SSO — see the theta-env README.

Access

  • Proxy (public): https://<host>/ — OpenResty front, auto-SSL (Let's Encrypt)
  • Management UI / API: http://127.0.0.1:3000/ (bound to localhost; the front proxies the UI under its own TLS)
  • Health: http://127.0.0.1:3000/health{"status":"ok"}

API tokens (personal access tokens)

Any logged-in user can mint a long-lived bearer token to call the management API from scripts/CI/other services, without an OIDC browser session. Tokens are self-service and authenticate as their creator: the creator's groups are snapshotted at mint time (mirroring how the proxy's browser session captures groups at login — the proxy never re-queries the IdP), and the existing authz layer (Permission.effectiveFor / roles.resolveEffective) applies unchanged. Local groups and owned-domain rights are recomputed live each request; only the SSO/LDAP group membership is the mint-time snapshot.

Create one in the UI under API Tokens (the token string is shown once), then use it as a bearer token:

curl -H "Authorization: Bearer prx_<id>_<secret>" https://proxy.example.com/api/host

Format: prx_<id>_<secret> — the id is the lookup key, the secret is bcrypt-hashed and never stored in plaintext. Rotate or revoke from the same page (immediate effect). Optional expiry (in days) at creation. Tokens persist in the bundled Redis (AOF — see Backups and restore), so they survive rebuilds.

The token carries the creator's effective rights: a global admin's token can manage Hosts/Users/Groups; a domain manager's token can manage their own domains but requireAdmin routes return 403. To tighten permissions after group changes, revoke and re-mint the token.

OpenResty runtime env

Variable Default Description
RESOLVER 127.0.0.11 DNS for upstream names in Host records (Docker's embedded DNS)
REAL_IP_FROM empty Trusted CIDR for X-Real-IP. Empty = proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front.

Auto-SSL / Let's Encrypt

lua-resty-auto-ssl stores certs in the bundled Redis. Redis is now AOF+RDB persisted to the proxy-data volume (not in-memory), so Let's Encrypt certs survive container recreation — no re-issue / rate-limit on rebuild. Port 80 is required for HTTP-01 challenges (mapped in the compose).

Backups and restore

What lives where

State Location Persisted?
Host records, permissions, DNS creds, local users proxy-data volume (/data, Redis) yes (AOF + RDB)
Let's Encrypt certs (auto-ssl) proxy-data volume (/data, Redis) yes — same Redis
nginx response cache / logs proxy-cache / proxy-logs volumes yes (volume)
Secrets (OIDC client secret, LDAP bind password) ./config/proxy-secrets.js (bind mount) your responsibility — back up off-host

Automatic snapshots — when run as part of the unified theta-env stack, setup.sh snapshots Redis + ./config/ to ./backups/<timestamp>/ before every rebuild and keeps the last BACKUP_KEEP (default 5). Standalone deployments should run ops/backup.sh the same way (on a cron/systemd timer, or by hand before an upgrade):

./ops/backup.sh              # keeps the last 5 by default
./ops/backup.sh 10           # or override retention
BACKUP_KEEP=10 ./ops/backup.sh

It snapshots Redis (BGSAVE, falling back to a synchronous SAVE if that doesn't complete quickly) and ./config/ to ./backups/<timestamp>/, pruning older backups beyond the retention count — the same approach theta-env's setup.sh uses, just scoped to this one container. Equivalent manual steps, if you'd rather not use the script:

# Redis — hot snapshot: trigger a save, then copy the RDB out
docker compose exec proxy redis-cli BGSAVE
docker compose cp proxy:/data/dump.rdb proxy-redis-$(date +%F).rdb

# Secrets — copy the config dir (holds OIDC client secret, LDAP bind password)
cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F)

Store the backup off the host — it contains secrets and the whole Host/permission/user dataset.

Restore — Redis (full proxy state + certs)

cp -a config-backup-<date> ./config && chmod 700 ./config
docker compose up -d
docker compose stop proxy
# AOF wins on startup — delete it so the RDB is loaded instead (see note).
docker compose run --rm --no-deps --entrypoint sh proxy -c \
  'rm -f /data/appendonly.aof /data/appendonly.aof.*'
docker compose cp proxy-redis-<date>.rdb proxy:/data/dump.rdb
docker compose start proxy

AOF vs RDB (important): with --appendonly yes, Redis loads appendonly.aof on startup and ignores dump.rdb if the AOF exists. To restore from an RDB snapshot you must delete the AOF first (the runbook does this); Redis then loads the RDB and writes a fresh AOF. Verify: docker compose exec proxy redis-cli DBSIZE.

Restoring Redis restores cert state at the snapshot time — certs issued after the snapshot are lost and will be re-issued on next request.

Upgrades

./setup.sh          # backs up, then rebuilds — proxy-data keeps Redis state
# (standalone) docker compose pull && docker compose up -d

Host records, permissions, DNS creds, local users, and Let's Encrypt certs all survive the rebuild because they live on the proxy-data volume, not in the image. Migrations note: if a release ships a nodejs/migrations/ script, run it after upgrading (it transforms in-Redis records); see the release notes.

Logs

OpenResty runs in the foreground and the Node app in the background, both writing to the container's stdout/stderr. nginx access/error logs go to files (/var/log/nginx, on the proxy-logs volume), so they do not appear in docker logs.

docker compose logs -f proxy                       # app + OpenResty (stdout/stderr)
docker compose exec proxy tail -f /var/log/nginx/error.log   # nginx errors
docker compose exec proxy tail -f /var/log/nginx/access.log  # nginx access
docker compose logs --tail=200 --since=10m proxy    # recent context

Method 2: Bare metal (Debian/Ubuntu)

ops/install.sh is an idempotent installer: it installs Node.js 22.x, OpenResty (from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to /opt/theta42/proxy, symlinks the OpenResty + systemd config from the repo, and starts proxy.service. Re-run it to update — it prints the version you're updating from and to (or "Already up to date" if there's nothing new).

wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash

or, if you already have the repo checked out:

sudo ./ops/install.sh

Configuration is file-based: on first run the installer seeds /etc/proxy/secrets.js from secrets.js.example (placeholders you must fill in — OIDC + LDAP values, see nodejs/conf/base.js for the shape). Edit it, then sudo systemctl restart proxy. Later runs never touch an existing secrets file.


Fronting an SSO Manager

The proxy is a natural front for theta42/sso-manager-node: it terminates TLS for the SSO's UI and protects it with OIDC login, while also binding to the SSO's LDAP directly for user lookups. To run both together:

  1. One Docker network so the proxy can reach the SSO internally at http://sso-manager:3001 (token/userinfo, server-to-server) and ldaps://sso-manager:636 (LDAP).
  2. Set the SSO's OAUTH_ISSUER to the browser-facing HTTPS URL the proxy serves the SSO at (e.g. https://sso.example.com).
  3. Register the proxy as an OIDC client in the SSO, with a redirectUri matching the proxy's callback (https://proxy.example.com/api/auth/oidc/callback).
  4. LDAP: point app_ldap__url at ldaps://sso-manager:636 and create a dedicated service account (cn=ldapclient,ou=people,…) — don't reuse the admin DN. For the SSO's self-signed LDAPS cert, set app_ldap__tlsOptions__rejectUnauthorized=false (or mount the cert and use app_ldap__tlsOptions__ca=<path>).

The theta42/theta-env unified repo automates all four steps with ./setup.sh.


Security notes

  1. Never commit secrets.js — it's in .gitignore.
  2. Bind the management port to localhost (the compose does: 127.0.0.1:3000). The OpenResty front proxies the UI under TLS; don't expose 3000 to the LAN.
  3. REAL_IP_FROM empty by default — the proxy trusts no one to set X-Real-IP (it's the front). Only set it if a trusted proxy sits in front.
  4. LDAPS for any LDAP that crosses the network. Use ldaps:///StartTLS; plain ldap:// is fine only on a private docker network.
  5. The image runs OpenResty workers as nobody and the node app as root (matches the bare-metal systemd unit). Harden to a non-root user for production if needed.