docs: remove standalone deployment and docs folder

This commit is contained in:
2026-08-02 00:51:30 -04:00
parent fbce59b1be
commit 36aa114d7c
24 changed files with 0 additions and 2165 deletions
-512
View File
@@ -1,512 +0,0 @@
# Deployment Guide — SSO Manager
Two supported deployment methods:
1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis (`docker compose up`).
2. **Bare metal**`install.sh` on Debian/Ubuntu (installs Node.js, OpenLDAP, Redis, the app, and a systemd unit).
## How configuration works
The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@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 (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_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `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 honor
> `app_*` env vars on 1.0.0. Before building the image, refresh the app's
> dependency lock from the `nodejs/` directory:
> ```bash
> cd nodejs && npm install @simpleworkjs/conf@^1.1.0
> ```
---
## Method 1: Docker (all-in-one)
The image (`Dockerfile.openldap`) bundles OpenLDAP, Redis, 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
The bundled `docker-compose.yml` reads config from a bind-mounted
`./config/sso-secrets.js` (not from a `.env` file). Copy the example, fill in
your secrets, then build + start:
```bash
mkdir -p config && chmod 700 config
cp secrets.js.example config/sso-secrets.js
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
docker compose up -d --build
```
`docker-entrypoint.sh` symlinks `/config/sso-secrets.js``/app/conf/secrets.js`
so `@simpleworkjs/conf` reads it, and pulls the server-side LDAP vars (base DN,
admin password, org, domain, cert CN, JWT secret) out of the same file. No
`app_*` env is passed — `app_*` env would override `secrets.js` (env beats the
file in `@simpleworkjs/conf`), so the file is kept authoritative.
> **Your domain is entered once, as the LDAP base DN.** Set `stack.ldapBaseDn`
> (e.g. `dc=718it,dc=biz`) and keep the LDAP DNs consistent with it — they all
> derive from that one value: `ldap.bindDN` = `cn=admin,<dn>`,
> `ldap.userBase` = `ou=people,<dn>`, `ldap.groupBase` = `ou=groups,<dn>`,
> and `stack.ldapDomain` = the dotted form (`718it.biz`). `oauth.issuer` is the
> public SSO URL (`https://<ssoHost>`). Drifting these apart (e.g. leaving
> `ldap.bindDN` at `dc=example,dc=com` while `stack.ldapBaseDn` is your real
> domain) makes the SSO bind against a non-existent root DN and every login
> fails with `Invalid Credentials`.
>
> Running the unified `theta-env` stack? You don't hand-edit these DNs at all
> — its `setup.sh` generates `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`)
> from a single `setup.env` (where the domain is asked once, as the base DN) with
> random secrets, and snapshots state before rebuilds — so the DNs can't drift.
> See the theta-env README.
**Quick test (defaults):** with no `./config/sso-secrets.js` the entrypoint
falls back to env-mode with safe defaults (`dc=example,dc=com`, admin password
`admin`, an auto-generated JWT secret) — fine for kicking the tires, not for
production.
**Advanced — env vars instead of the file:** the entrypoint also supports
config via `LDAP_*` / `app_*` env vars (env-mode, used when
`/config/sso-secrets.js` is absent). Since the bundled compose no longer passes
those env vars, you'd add them to its `environment:` block yourself, e.g.
`LDAP_ADMIN_PASS`, `JWT_SECRET`, `app_oauth__issuer`. This is mainly for
bare-metal / advanced standalone use; most deployments should use the file.
### What the entrypoint does
`docker-entrypoint.sh` (run as the container entrypoint):
1. If `/config/sso-secrets.js` is mounted, symlinks it to `/app/conf/secrets.js`
and reads the server-side LDAP vars from it (secrets.js mode). Otherwise it
derives them from `LDAP_*` env vars with safe defaults (env mode).
2. Generates a self-signed TLS cert (unless one is already present at
`LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb`
database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS,
indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf`
listening on `ldap:///` (389) and `ldaps:///` (636).
3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default
`pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`,
`app_sso_oauth_admin`, `app_sso_service_account`) — idempotently, so
container restarts are safe.
4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and
stores OAuth clients there), AOF+RDB persisted to `/data`, unless
`app_redis__host` is set (then it's expected to be external).
5. In env mode, exports `app_*` env vars so the app binds to the local slapd. In
secrets.js mode it exports none (the app reads the file directly).
6. `exec`s `node 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 (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
### 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 a browser session. Tokens are
self-service and authenticate **as their creator** — a token carries the
creator's LDAP group permissions, so the same `permission.byGroup` checks apply
(group membership is re-resolved from LDAP live on each request).
Create one in the UI under **API Tokens** (the token string is shown **once**),
then use it as a bearer token:
```bash
curl -H "Authorization: Bearer sso_<id>_<secret>" https://sso.example.com/api/user
```
Format: `sso_<id>_<secret>` — the `id` is the lookup key, the `secret` is
bcrypt-hashed and never stored in plaintext. Rotate or revoke a token from the
same UI page; revocation takes effect immediately. Optional expiry (in days) at
creation. API tokens persist in the bundled Redis, so they survive rebuilds
(Redis is persisted via AOF — see *Backups and restore*).
The token has the same access as a browser session for that user — an
`app_sso_admin`'s token can manage users/groups; a non-admin's token is limited
to what they could do in the UI.
### 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).
```bash
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) |
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
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.
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
recommended network layouts and how to match the cert SAN to the hostname.
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
out of the container and add it to the client's trusted CA store, or set
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
```bash
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
```
- **Use your own cert** (CA-signed / internal CA): replace the `ldap-certs` named
volume with a bind mount containing your own `ldap.crt` + `ldap.key`:
```yaml
volumes:
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
```
The entrypoint leaves existing certs untouched (idempotent).
> 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 `389` mapping in `docker-compose.yml` only 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`](https://github.com/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:
1. **Put them on one Docker network** so the proxy can reach the SSO Manager
internally at `http://sso-manager:3001` for token/userinfo (server-to-server),
without exposing the SSO Manager's HTTP port to the internet:
```yaml
# in the proxy's compose, or a shared external network:
networks:
- sso-net
```
2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy
serves the SSO at (e.g. `https://sso.yourdomain.com`). The proxy's
`oidc.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 internal `http://sso-manager:3001` URL; only the issuer/redirect URLs must
be public.
3. **Register the proxy as an OAuth/OIDC client** in the SSO Manager UI, with a
`redirectUri` matching the proxy's callback (e.g.
`https://proxy.yourdomain.com/api/auth/oidc/callback`), and put the client
secret in the proxy's `secrets.js`.
4. **LDAP for the proxy**: point the proxy's `ldap.url` at
`ldaps://sso-manager:636` (TLS, same Docker network) rather than a LAN IP, and
create a dedicated LDAP service account under `ou=people` (e.g.
`cn=ldapclient,ou=people,…`) via the SSO Manager UI — don't reuse the admin DN.
### Backups and restore
**What lives where**
| State | Location | Persisted? |
|-------|----------|------------|
| LDAP directory (users, groups, policies) | `ldap-data` volume (`/var/lib/ldap`) | yes (volume) |
| LDAP TLS cert | `ldap-certs` volume (`/etc/openldap/certs`) | yes (volume) |
| Redis (OAuth clients, tokens, sessions) | `sso-data` volume (`/data`) | yes (AOF + RDB) |
| Secrets (LDAP admin pass, JWT secret, SMTP) | `./config/sso-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 LDAP + 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):
```bash
./ops/backup.sh # keeps the last 5 by default
./ops/backup.sh 10 # or override retention
BACKUP_KEEP=10 ./ops/backup.sh
```
It snapshots LDAP (`slapcat`, auto-detecting your base DN from
`./config/sso-secrets.js`), 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:
```bash
# LDAP — full directory export (works while slapd is running)
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
# Redis — hot snapshot: trigger a save, then copy the RDB out
docker compose exec sso-manager redis-cli BGSAVE
docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb
# Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.)
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 user
directory.
**Restore — full (disaster recovery)**
The SSO image uses a static `slapd.conf` (slapd starts with `-f`, not `-F`
cn=config), so LDAP restore uses `slapadd -f /etc/openldap/slapd.conf`:
```bash
# 1. Secrets
cp -a config-backup-<date> ./config && chmod 700 ./config
./setup.sh # fresh empty volumes (or: docker compose up -d)
docker compose stop sso-manager
# 2. LDAP — wipe the mdb files, then load the LDIF into the stopped directory
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
< ldap-backup-<date>.ldif
docker compose start sso-manager
# 3. Redis — see the AOF note below
docker compose stop sso-manager
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /data/appendonly.aof /data/appendonly.aof.*' # REQUIRED — see note
docker compose cp sso-redis-<date>.rdb sso-manager:/data/dump.rdb
docker compose start sso-manager
```
**Restore — Redis only** = step 3 above. **Restore — LDAP only** = step 2 above.
> **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** (step 3 does
> this); Redis then loads the RDB and writes a fresh AOF. Verify after restoring:
> `docker compose exec sso-manager redis-cli DBSIZE` and
> `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
**Upgrades**
```bash
./setup.sh # backs up, then rebuilds — volumes keep LDAP + Redis state
# (standalone) docker compose pull && docker compose up -d
```
LDAP data and Redis state survive the rebuild because they live on named
volumes, not in the image. Verify health (`docker compose ps`, log in, check an
OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
OAuth clients live in SSO Redis and are preserved by the volume.
> **Note — the bundled slapd is built from source.** The all-in-one image
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
> shape as "Restore — LDAP only" above. Verify after a rebuild:
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
> as available.
---
## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
config from the repo. 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).
On the **first run only** it also installs and configures OpenLDAP (modules +
overlays + custom schema + directory tree + required groups — see
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
file exists it's never touched again, and LDAP is never re-bootstrapped —
edit the file and restart the service to change anything.
### Prerequisites
- Debian 11+ / Ubuntu 20.04+
- Root (`sudo`)
- Internet access
### Install
```bash
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
or, if you already have the repo checked out:
```bash
sudo ./install.sh
```
| Env var | Description |
|---------|-------------|
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
| `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
| `PORT` | HTTP port (default `3001`) — first run only |
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
### Post-install
```bash
sudo systemctl status sso-manager
journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
```
### What `install.sh` does
1. Installs Node.js 22.x (NodeSource) and Redis.
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
runs `npm ci --omit=dev`.
5. Enables and (re)starts the service.
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
> starting the service. To (re)configure overlays on an already-installed
> slapd, use `ops/ldap-setup.sh` directly (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 `theta42Person` auxiliary objectClass with `dateOfBirth`
(OID `1.3.6.1.4.1.99999.x`) — see `ops/ldap-setup.sh` for the LDIF.
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a
default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
- **Required groups:** `app_sso_admin` (full admin), `app_sso_invite` (invitation
management), `app_sso_oauth_admin` (OAuth client management),
`app_sso_service_account` (not a permission — marks a `posixAccount` as a
non-person service account; see docs/ldap.md).
`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.js` into your gitignored
`conf/secrets.js`, **or** set them as `app_*` env vars. Secret values (LDAP bind
password, SMTP password, JWT secret) already belong in `secrets.js`.
- After the change, verify the merged config: `node -e "console.log(require('@simpleworkjs/conf'))"` from the `nodejs/` 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:
```bash
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:
```bash
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+:
```bash
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
```
### LDAP connection refused
```bash
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
1. **Never commit `secrets.js`** — it's in `.gitignore`.
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
set `JWT_SECRET`, issued tokens invalidate on container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
proxy and keep `3001` on the Docker network / localhost only.
5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to the
host by default for LAN/VPN clients that bind LDAP directly (other hosts
running `ldap-client`, apps with their own LDAP auth settings) — not for
exposure through your router/firewall. LDAP simple-bind is a brute-force
target with no rate limiting in front of it the way the HTTP login endpoints
have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale,
WireGuard, …) instead of forwarding 636 publicly.
6. The all-in-one image runs slapd as the `ldap` user but the app process as root
(matches the bare-metal systemd unit). Harden the app to a non-root user for
production if needed.