# theta-suite — unified SSO Manager + Proxy. # # Brings up the two all-in-one images on one bridge network so the proxy can # reach the SSO internally (http://sso-manager:3001 for token/userinfo, # ldaps://sso-manager:636 for LDAP) without exposing the SSO's HTTP port to the # internet. The proxy is the public front (80/443); the SSO sits behind it. # # Each project builds from its git submodule: # ./sso-manager-node -> Dockerfile.openldap (app + OpenLDAP + Redis) # ./proxy -> Dockerfile (OpenResty + app + Redis) # So `git clone --recursive` is required to get the submodules first. # # Config + secrets live in bind-mounted ./config/ (gitignored): # ./config/sso-secrets.js — SSO app + orchestrator config # ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config # Each app's entrypoint points CONF_SECRETS at its file so @simpleworkjs/conf # (>= 1.2.0) reads it directly -- no app_* env is passed (app_* env would # override secrets.js), and no write access to /app/conf is needed. The # sso-manager mounts ./config read-write so the bootstrap can write the # generated OAuth client creds back into proxy-secrets.js; the proxy mounts # it read-only. # # Compose only interpolates the port defaults below — there is no .env file. # First-run wiring (LDAP service account, first admin, OAuth client) is # automated by ./setup.sh, which runs bootstrap/bootstrap.js inside the # sso-manager container. services: sso-manager: build: context: ./sso-manager-node dockerfile: Dockerfile.openldap args: # A submodule's .git is a pointer file, not a real repo — the image # can't resolve its own commit hash from inside the build context. # setup.sh sets this from the host, where the submodule resolves # correctly (git -C sso-manager-node rev-parse --short HEAD). GIT_COMMIT: ${SSO_GIT_COMMIT:-} # Optional upstream HTTP(S) proxy for npm/apt during the build (NOT # the theta42 "proxy" app). Set CFG_HTTP_PROXY in setup.env; empty by # default, so this is a no-op unless configured. HTTP_PROXY: ${CFG_HTTP_PROXY:-} HTTPS_PROXY: ${CFG_HTTPS_PROXY:-} NO_PROXY: ${CFG_NO_PROXY:-} container_name: sso-manager restart: unless-stopped depends_on: - openbao networks: [theta-net] ports: # SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so # the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to # lock it to localhost once the proxy fronts it at https://. - "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001" # LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack # host's OWN enrollment: setup.sh / ldap-client configure the host's sssd # against ldap://localhost and ldaps://localhost, and the LDAP server is # co-located on this host, so BOTH ports must be reachable from the host # over loopback — not only over the docker network. Bind 0.0.0.0 (default) # so LAN clients can use the host's local IP too; set LDAP_BIND and/or # LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal # hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet. - "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389" - "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636" environment: # Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from # ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf # deep-merges secret/sso-manager/conf from OpenBao over it at boot # (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other # env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by # setup.sh (policy sso-broker) — NOT the root token. - NODE_ENV=production - NODE_PORT=3001 # Only a first-run default (site_config.js's envDefaults()) -- a real # join/promote persists its own value to /config/site.json afterward, # which always wins. Derived by setup.sh from CFG_SITE_NAME. - SITE_SLUG=${SITE_SLUG:-} - LDAP_SERVER_ID=${LDAP_SERVER_ID:-} - LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-} # utils/proxy_client.js (no-inbound relay automation) and # utils/jump_client.js (real mesh-gateway count on the Multi-Site # modal) both no-op/skip without these -- neither was ever actually # wired into the compose environment before, so both features were # unreachable in every real deployment despite existing in code. - PROXY_INTERNAL_URL=http://proxy:3000 - JUMP_INTERNAL_URL=http://jump-host:3002 - VAULT_ADDR=http://openbao:8200 - VAULT_TOKEN=${SSO_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at # runtime. See the build args above for the same setting during build. - HTTP_PROXY=${CFG_HTTP_PROXY:-} - HTTPS_PROXY=${CFG_HTTPS_PROXY:-} - NO_PROXY=${CFG_NO_PROXY:-} volumes: # The host docker socket so the bundled Docker discovery plugin (seeded as # 'docker-local' with socketPath /var/run/docker.sock) can list containers. # Without this the plugin errors with ENOENT and shows 'Last run: error'. - /var/run/docker.sock:/var/run/docker.sock # Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap # can write the generated OAuth client creds into proxy-secrets.js. The # entrypoint points CONF_SECRETS at /config/sso-secrets.js. - ./config:/config # Persist the LDAP database across container recreation. - ldap-data:/var/lib/ldap # Persist the auto-generated self-signed TLS cert so clients don't have to # re-trust it on every rebuild. - ldap-certs:/etc/openldap/certs # Persist Redis (AOF + RDB) so OAuth clients, tokens, and other Redis state # survive container recreation. - sso-data:/data # Bind-mount the bootstrap script so `docker compose exec sso-manager node # /bootstrap/bootstrap.js` can run it (read-only). - ./bootstrap:/bootstrap:ro healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] interval: 30s timeout: 5s retries: 3 start_period: 30s proxy: build: context: ./proxy dockerfile: Dockerfile args: # A submodule's .git is a pointer file, not a real repo — the image # can't resolve its own commit hash from inside the build context. # setup.sh sets this from the host, where the submodule resolves # correctly (git -C proxy rev-parse --short HEAD). GIT_COMMIT: ${PROXY_GIT_COMMIT:-} # Optional upstream HTTP(S) proxy for npm/apt during the build. See # the sso-manager service above for details. HTTP_PROXY: ${CFG_HTTP_PROXY:-} HTTPS_PROXY: ${CFG_HTTPS_PROXY:-} NO_PROXY: ${CFG_NO_PROXY:-} container_name: proxy restart: unless-stopped networks: [theta-net] depends_on: sso-manager: condition: service_healthy openbao: condition: service_started ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" - "${HTTPS_ALT_PORT:-4443}:4443" # Management UI/API. Bind address is configurable via MGMT_BIND (default # 0.0.0.0 so it's reachable on the LAN during setup). Set MGMT_BIND=127.0.0.1 # to lock it to localhost once the proxy fronts it under TLS. - "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000" environment: # oidc/ldap/auth config is loaded by @simpleworkjs/conf from # ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf # deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth # clientSecret is consumed at require time, so bao-conf.init() runs # BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env # the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN # (policy proxy — read only secret/proxy/conf). - NODE_ENV=production - NODE_PORT=3000 - VAULT_ADDR=http://openbao:8200 - VAULT_TOKEN=${PROXY_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's # Encrypt, DNS providers) at runtime. - HTTP_PROXY=${CFG_HTTP_PROXY:-} - HTTPS_PROXY=${CFG_HTTPS_PROXY:-} - NO_PROXY=${CFG_NO_PROXY:-} volumes: # Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy # only reads it; the sso-manager bootstrap writes the OAuth creds. The # entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a # fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is # unreachable the app boots from this file instead. - ./config:/config:ro # Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local # users, AND the auto-ssl Let's Encrypt certs survive container recreation. - proxy-data:/data - proxy-cache:/var/cache/nginx/proxy - proxy-logs:/var/log/nginx # OPTIONAL, for strict LDAPS trust (see README "Security notes"): mount # the SSO's self-signed cert into the proxy read-only, then set # ldap.tlsOptions.ca= in ./config/proxy-secrets.js. # - ldap-certs:/etc/ssl/sso-ldap-certs:ro healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] interval: 30s timeout: 5s retries: 3 start_period: 30s # SSH jump host — a core component, always built + started alongside the # SSO and proxy. Authenticates users against the SSO's OpenLDAP, resolves # reachable hosts from the directory API, and bridges SSH through. jump-host: build: context: ./jump-host dockerfile: Dockerfile args: GIT_COMMIT: ${JUMP_GIT_COMMIT:-} # Optional upstream HTTP(S) proxy for npm/apt during the build. See # the sso-manager service above for details. HTTP_PROXY: ${CFG_HTTP_PROXY:-} HTTPS_PROXY: ${CFG_HTTPS_PROXY:-} NO_PROXY: ${CFG_NO_PROXY:-} container_name: jump-host restart: unless-stopped networks: [theta-net] depends_on: sso-manager: condition: service_healthy openbao: condition: service_started ports: - "${JUMP_SSH_PORT:-2222}:2222" # SSH front door - "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API environment: - NODE_ENV=production # Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js, # then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from # OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN # (policy jump-host — read only secret/jump-host/conf). - VAULT_ADDR=http://openbao:8200 - VAULT_TOKEN=${JUMP_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (the directory API # client) at runtime. - HTTP_PROXY=${CFG_HTTP_PROXY:-} - HTTPS_PROXY=${CFG_HTTPS_PROXY:-} - NO_PROXY=${CFG_NO_PROXY:-} volumes: - ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap) - jump-data:/var/lib/jump-host # generated host keys persist here - jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here # A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for # testing jump-host's actual key-injection -> upstream-connect flow -- # a container with a manually-dropped public key in authorized_keys never # exercises the LDAP-key-serving path a real production host does. Built # from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars # for setup notes. Opt-in test fixture: bring it up explicitly with # `docker compose --profile ldap-test up` (jump-host itself now starts # unconditionally, so this only adds a downstream host for it to reach). ldap-test-host: profiles: ["ldap-test"] build: context: ./ldap-client dockerfile: Dockerfile container_name: ldap-test-host hostname: ldap-test-host restart: unless-stopped networks: [theta-net] depends_on: sso-manager: condition: service_healthy privileged: false volumes: - ./config/ldap-test-host.vars:/config/ldap.vars:ro - ./config/ldap-ca.crt:/config/ldap-ca.crt:ro # Renews the three periodic service tokens (theta-svc role, 768h period) # every 12h. Periodic tokens live forever ONLY while something renews them — # this sidecar is that something, so the stack survives arbitrarily long # uptimes and the tokens in .env never silently expire. If a token is missing # or already dead it just logs and moves on (setup.sh re-mints on next run). bao-renewer: image: quay.io/openbao/openbao:latest container_name: bao-renewer restart: unless-stopped depends_on: - openbao environment: - BAO_ADDR=http://openbao:8200 - SSO_VAULT_TOKEN=${SSO_VAULT_TOKEN:-} - PROXY_VAULT_TOKEN=${PROXY_VAULT_TOKEN:-} - JUMP_VAULT_TOKEN=${JUMP_VAULT_TOKEN:-} entrypoint: ["/bin/sh", "-c"] command: - | renew() { if [ -z "$$2" ]; then return 0; fi if BAO_TOKEN="$$2" bao token renew > /dev/null 2>&1; then echo "[bao-renewer] renewed $$1" else echo "[bao-renewer] FAILED to renew $$1 (expired/revoked? re-run setup.sh to re-mint)" fi } while true; do renew SSO_VAULT_TOKEN "$$SSO_VAULT_TOKEN" renew PROXY_VAULT_TOKEN "$$PROXY_VAULT_TOKEN" renew JUMP_VAULT_TOKEN "$$JUMP_VAULT_TOKEN" sleep 43200 done networks: - theta-net openbao: image: quay.io/openbao/openbao:latest container_name: openbao restart: unless-stopped cap_add: - IPC_LOCK command: server -config=/vault/config/openbao.hcl environment: - BAO_ADDR=http://127.0.0.1:8200 ports: - "8080:8200" volumes: - ./config/openbao.hcl:/vault/config/openbao.hcl:ro - openbao-data:/vault/data networks: - theta-net networks: theta-net: driver: bridge volumes: ldap-data: ldap-certs: sso-data: proxy-data: proxy-cache: proxy-logs: jump-data: jump-redis-data: openbao-data: