Document how to get logs when running the all-in-one image: docker compose logs for the app + slapd (both stdout/stderr, slapd runs -d 0), plus a direct ldapsearch health check. Added to README.md, DEPLOYMENT.md (Method 1), and the GitHub Pages docs/deployment.md. Co-Authored-By: Claude <noreply@anthropic.com>
15 KiB
Deployment Guide — SSO Manager
Two supported deployment methods:
- Docker — a single all-in-one image bundling the app + OpenLDAP (
docker compose up). - Bare metal —
install.shon Debian/Ubuntu (installs Node.js, OpenLDAP, the app, and a systemd unit).
How configuration works
The app loads configuration via @simpleworkjs/conf, which deep-merges, in order:
conf/base.js(committed, generic defaults)conf/<NODE_ENV>.js(optional)conf/secrets.js(gitignored — secrets + per-deployment values)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 (numbers, booleans, null, JSON) and kept
as raw strings otherwise. Examples:
| Env var | Sets | Type |
|---|---|---|
app_ldap__url=ldap://host:389 |
conf.ldap.url |
string |
app_ldap__bindPassword=secret |
conf.ldap.bindPassword |
string |
app_oauth__jwtSecret=... |
conf.oauth.jwtSecret |
string |
app_smtp__secure=false |
conf.smtp.secure |
boolean |
app_oauth__token_lifetime__access_token=3600 |
conf.oauth.token_lifetime.access_token |
number |
app_name=My SSO |
conf.name |
string |
Requires
@simpleworkjs/conf>= 1.1.0. The Docker image will not honorapp_*env vars on 1.0.0. Before building the image, refresh the app's dependency lock from thenodejs/directory:cd nodejs && npm install @simpleworkjs/conf@^1.1.0
Method 1: Docker (all-in-one)
The image (Dockerfile.openldap) bundles OpenLDAP and the app in one container.
The app connects to the bundled slapd over localhost:389 automatically; you only
need to set a few secrets.
Setup
# Minimal: set an LDAP admin password and a JWT secret, then build + start.
LDAP_ADMIN_PASS='choose-a-strong-password' \
JWT_SECRET="$(openssl rand -hex 32)" \
docker compose up -d --build
For a customized deployment, put the overrides in a .env file next to
docker-compose.yml:
LDAP_BASE_DN=dc=yourdomain,dc=com
LDAP_DOMAIN=yourdomain.com
LDAP_ADMIN_PASS=your-admin-password
ORG_NAME=Your Org
JWT_SECRET=your-jwt-secret
OAUTH_ISSUER=https://sso.yourdomain.com # browser-facing URL the proxy serves
LDAP_CERT_CN=sso.yourdomain.com # hostname LDAPS clients verify against
SMTP_HOST=smtp.yourdomain.com
SMTP_PORT=587
SMTP_USER=noreply@yourdomain.com
SMTP_PASS=your-smtp-password
SMTP_FROM=Your Org <noreply@yourdomain.com>
PORT=3001
LDAPS_PORT=636
# LDAP_PORT=389 # uncomment the 389 host mapping in compose if you need plain LAN binds
Then docker compose up -d --build.
What the entrypoint does
docker-entrypoint.sh (run as the container entrypoint):
- Generates a self-signed TLS cert (unless one is already present at
LDAP_CERT_DIR), generates aslapd.conffor the bundled OpenLDAP (mdbdatabase,pw-sha2/ppolicy/memberof/refintmodules + overlays, TLS, indexes, access controls), and startsslapd -f /etc/openldap/slapd.conflistening onldap:///(389) andldaps:///(636). - Seeds the directory (base DN,
ou=people/ou=groups/ou=policies, a defaultpwdPolicy, and the required SSO groupsapp_sso_admin,app_sso_invite,app_sso_oauth_admin) — idempotently, so container restarts are safe. - Starts a bundled Redis (the app uses
model-redisfor models/sessions), unlessapp_redis__hostis set (then it's expected to be external). - Exports
app_*env vars so the app binds to the local slapd (anyapp_*you set in the compose environment wins over the entrypoint's defaults). execsnode bin/www.
Access
- SSO Manager UI:
http://localhost:3001(HTTP inside the container — put a TLS-terminating proxy in front for browser access) - Health check:
http://localhost:3001/health→{"status":"ok"} - OIDC discovery:
http://localhost:3001/.well-known/openid-configuration - LDAP (internal, app↔slapd):
ldap://localhost:389(not mapped to the host) - LDAPS (for legacy apps / direct binds):
ldaps://<host>:636(TLS)
Logs
The all-in-one image runs the Node app and slapd (OpenLDAP) in one container,
both writing to the container's stdout/stderr, so docker compose logs is the
primary view (slapd runs with -d 0, so LDAP output is there too).
docker compose logs -f sso-manager # app + slapd (stdout/stderr)
docker compose logs --tail=200 --since=10m sso-manager # recent context
docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
-D "cn=admin,$LDAP_BASE_DN" -W -b "$LDAP_BASE_DN" # LDAP health check
Available environment variables
| Variable | Default | Description |
|---|---|---|
LDAP_BASE_DN |
dc=example,dc=com |
slapd suffix + app user/group base |
LDAP_DOMAIN |
derived from LDAP_BASE_DN |
DNS domain; default for LDAP_CERT_CN and OAuth issuer |
LDAP_ADMIN_PASS |
admin |
slapd root password + app bind password |
ORG_NAME |
SSO Manager |
org name in UI/email/group descriptions |
JWT_SECRET |
auto-generated | OAuth JWT signing secret (persist it!) |
OAUTH_ISSUER |
https://sso.<LDAP_DOMAIN> |
OIDC issuer in the discovery doc (browser-facing URL) |
LDAP_CERT_CN |
LDAP_DOMAIN |
CN/SAN on the LDAPS cert (hostname clients verify against) |
LDAP_CERT_DIR |
/etc/openldap/certs |
where the entrypoint looks for ldap.crt+ldap.key (mount your own here) |
SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM |
localhost / 587 / empty | outbound email |
PORT |
3001 |
host port mapped to the UI |
LDAPS_PORT |
636 |
host port mapped to LDAPS |
LDAP_PORT |
389 |
uncomment the host mapping in compose to expose plain LDAP (not recommended) |
Any app_* var may also be set directly to override any config value (see the
table at the top).
LDAP TLS (LDAPS / StartTLS)
The bundled slapd generates a self-signed cert on first start (CN = LDAP_CERT_CN,
valid 10 years, SAN includes the CN + localhost + 127.0.0.1) and listens on
ldaps:/// (636) plus offers StartTLS on ldap:/// (389). The cert is stored on the
ldap-certs volume so it persists across container recreation — clients don't need
to re-trust on every rebuild.
- Trusting the self-signed cert (clients): copy
/etc/openldap/certs/ldap.crtout of the container and add it to the client's trusted CA store, or setTLS_REQCERT neverfor quick-and-dirty LAN use. Fetch it with:docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt - Use your own cert (CA-signed / internal CA): replace the
ldap-certsnamed volume with a bind mount containing your ownldap.crt+ldap.key:The entrypoint leaves existing certs untouched (idempotent).volumes: - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
Port 389 (plain LDAP) is not mapped to the host by default, to avoid cleartext password binds over the LAN. Direct-LDAP clients should use LDAPS (636) or StartTLS. Uncomment the
389mapping indocker-compose.ymlonly if you need plain LAN binds and accept the risk.
Fronting with a reverse proxy (theta42/proxy)
The SSO Manager runs HTTP inside the container; terminate TLS at a front proxy.
The theta42/proxy is an OIDC-protected reverse
proxy and a natural fit — it's both an OIDC client of the SSO Manager and a
direct LDAP client for user lookups. To run both together:
- Put them on one Docker network so the proxy can reach the SSO Manager
internally at
http://sso-manager:3001for token/userinfo (server-to-server), without exposing the SSO Manager's HTTP port to the internet:# in the proxy's compose, or a shared external network: networks: - sso-net - Set the SSO's
OAUTH_ISSUERto the browser-facing HTTPS URL the proxy serves the SSO at (e.g.https://sso.yourdomain.com). The proxy'soidc.issuer/endpoints must match — it can get them from the SSO's/.well-known/openid-configuration. Server-to-server calls from the proxy go to the internalhttp://sso-manager:3001URL; only the issuer/redirect URLs must be public. - Register the proxy as an OAuth/OIDC client in the SSO Manager UI, with a
redirectUrimatching the proxy's callback (e.g.https://proxy.yourdomain.com/api/auth/oidc/callback), and put the client secret in the proxy'ssecrets.js. - LDAP for the proxy: point the proxy's
ldap.urlatldaps://sso-manager:636(TLS, same Docker network) rather than a LAN IP, and create a dedicated LDAP service account underou=people(e.g.cn=ldapclient,ou=people,…) via the SSO Manager UI — don't reuse the admin DN.
Backups (small-business / ~100 users)
- LDAP data lives on the
ldap-datavolume (/var/lib/ldapin the container). Back up the directory with anldapsearch/slapcatexport on a schedule:(Restorable withdocker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldifldapadd/ldapmodifyagainst a fresh instance.) - Redis is in-memory and not persisted by default (session/cache only — safe
to lose). If you want session durability, mount a Redis AOF/RDB volume and
enable persistence in
docker-entrypoint.sh. - JWT_SECRET and LDAP_ADMIN_PASS are operational secrets — store them
outside the container (your
.env, a password manager, etc.).
Method 2: Bare metal (Debian/Ubuntu)
install.sh is an idempotent installer: it installs Node.js 20.x, installs and
configures OpenLDAP (modules + overlays + custom schema + directory tree +
required groups), deploys the app to /opt/sso-manager, and creates a systemd
unit. Configuration is written to /opt/sso-manager/conf/secrets.js (file-based).
Prerequisites
- Debian 11+ / Ubuntu 20.04+
- Root (
sudo) - Internet access
Install
sudo ./install.sh \
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
| Flag | Env var | Description |
|---|---|---|
-p, --admin-pass |
LDAP_ADMIN_PASS |
LDAP admin password (required) |
-b, --base-dn |
LDAP_BASE_DN |
Base DN (default dc=example,dc=com) |
-n, --org-name |
ORG_NAME |
Org name (default SSO Manager) |
-o, --port |
PORT |
HTTP port (default 3001) |
-j, --jwt-secret |
JWT_SECRET |
JWT secret (default auto-generated) |
-s, --smtp-config |
SMTP_* |
SMTP as host:port:user:pass |
--skip-ldap |
SKIP_LDAP |
Skip LDAP setup (use existing) |
--skip-app |
SKIP_APP |
LDAP setup only |
--dry-run |
DRY_RUN |
Show actions without making changes |
Post-install
sudo systemctl enable --now sso-manager
journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
What install.sh does
- Installs Node.js 20.x (NodeSource).
- Installs OpenLDAP (
slapd) with:pw-sha2,ppolicy,memberof,refintmodules + overlays; the customtheta42Personschema (dateOfBirth); indexes;ou=people/ou=groups/ou=policies; a defaultpwdPolicy; and the SSO groups. - Installs the app to
/opt/sso-managerand runsnpm ci --omit=dev. - Generates
conf/secrets.js(LDAP/SMTP/JWT) andconf/base.js(generic defaults). - Installs
sso-manager.service(systemd), enabled on boot.
For an existing LDAP server, run
sudo ./install.sh --skip-ldap …and point the app at it. For LDAP-only setup on a host that already runs the app elsewhere, use--skip-app. To (re)configure overlays on an already-installed slapd, preferops/ldap-setup.sh(idempotent, auto-detects the user database).
LDAP requirements (for any external LDAP server)
The app needs these on the LDAP server:
- Modules:
pw-sha2(the app stores user passwords as{SSHA512}),ppolicy,memberof,refint. - Custom schema: the
theta42Personauxiliary objectClass withdateOfBirth(OID1.3.6.1.4.1.99999.x) — seeops/ldap-setup.shfor the LDIF. - Directory tree:
ou=people,ou=groups,ou=policiesunder the base DN, a defaultpwdPolicyatcn=ppolicy,ou=policies,<base>. - Required groups:
app_sso_admin(full admin),app_sso_invite(invitation management),app_sso_oauth_admin(OAuth client management).
ops/ldap-setup.sh -p <admin-password> configures all of the above idempotently
against a running slapd (auto-detects the database holding your base DN, and
verifies pwdAccountLockedTime is live — the attribute the app's
active/inactive toggle depends on).
Migrating an existing instance to the generic defaults
The committed nodejs/conf/base.js now ships generic defaults
(dc=example,dc=com, localhost, SSO Manager). Previously it carried
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth issuer).
If you run an existing instance off this repo:
- Move those per-deployment, non-secret values (bind DN, user/group bases, SMTP
host/user/sender, OAuth issuer, org name) from
base.jsinto your gitignoredconf/secrets.js, or set them asapp_*env vars. Secret values (LDAP bind password, SMTP password, JWT secret) already belong insecrets.js. - After the change, verify the merged config:
node -e "console.log(require('@simpleworkjs/conf'))"from thenodejs/directory.
Troubleshooting
503 OpenLDAP ppolicy overlay is not configured
The ppolicy overlay isn't attached to the database holding your users, so the
active/inactive toggle can't set pwdAccountLockedTime. Run:
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
App starts but LDAP operations 401 / "Invalid Credentials"
Check the merged LDAP config the app actually sees:
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
Confirm url/bindDN/bindPassword/userBase match your directory. Remember
app_* env vars override secrets.js which overrides base.js.
app_* env vars seem to do nothing
You're on @simpleworkjs/conf 1.0.0. Bump to 1.1.0+:
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
LDAP connection refused
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd # bare metal
netstat -tlnp | grep 389
Security notes
- Never commit
secrets.js— it's in.gitignore. - Use LDAPS / StartTLS for any LDAP connection that crosses the network. The
bundled slapd listens on
ldaps:///(636, TLS) andldap:///(389, plain + StartTLS); port 389 is not mapped to the host by default so LAN clients can't bind in cleartext. Direct-LDAP apps (legacy services,theta42/proxy) should useldaps://…:636or StartTLS. - Persist
JWT_SECRET— if the Docker image auto-generates one and you don't setJWT_SECRET, issued tokens invalidate on container recreation. - Don't expose the UI's HTTP port to the internet — terminate TLS at a front
proxy and keep
3001on the Docker network / localhost only. - The all-in-one image runs slapd as the
ldapuser but the app process as root (matches the bare-metal systemd unit). Harden the app to a non-root user for production if needed.