# Hub service installation The initial production target is a supported Linux host with systemd, PostgreSQL, Node.js 20 or newer, `pg_isready`, `runuser`, `setpriv`, bubblewrap, `socat` and `cph`. The Hub runs as the dedicated non-login `cph-hub` user; do not create or run the unit as root. Build or deploy the Hub under `/srv/curriculum-project-hub`, then run: ```sh sudo BASE=/srv/curriculum-project-hub \ HUB_DIR=/srv/curriculum-project-hub/hub \ bash /srv/curriculum-project-hub/hub/deploy/install_service.sh ``` On a clean host the first invocation creates `/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned `secret-keyring.json`, then exits with status 78. Copy the keyring to a separately protected recovery location before continuing; it must not live only on this host or in the ordinary database/workspace backup. Fill every required environment blank, set `CPH_BIN` to an executable compatible `cph`, keep both source files root-only at mode `0600`, and run the same command again. No systemd unit is installed until configuration, paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory ownership all pass preflight. The service account cannot read the keyring source. The installed unit uses systemd `LoadCredential` to materialize a read-only `cph-secret-keyring` for the running service. Hub refuses production startup if this credential is missing, malformed, or does not contain its declared active key. Provider credentials are configured through the Organization admin connection API, never through process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings. Rotate the local KEK only from the controlled host console. The command first atomically adds a new active key while retaining every previous key, then authenticates and rewraps every stored Provider DEK, writes redacted org audit events, and verifies the complete set again: ```sh sudo bash -c ' set -euo pipefail systemctl stop cph-hub.service set -a . /srv/curriculum-project-hub/.secrets/platform.env set +a node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \ --keyring-file /srv/curriculum-project-hub/.secrets/secret-keyring.json systemctl start cph-hub.service ' ``` The CLI refuses to rotate while `cph-hub.service` is active; stopping the only writer prevents a concurrent BYOK version from being appended under the old in-memory keyring. A PostgreSQL transaction advisory lock excludes a second rotation and is released automatically if the CLI or host crashes. The operation is therefore rerunnable after interruption: old and new keys remain in the atomically replaced keyring, so partially rewrapped rows stay decryptable. It also refuses success while any envelope still names a non-active KEK. Back up the updated keyring to the separate recovery location and complete a restore preflight before considering old-key retirement. The pilot deliberately does not auto-delete previous keys. The deployment path also runs `npm run audit:production` from the locked clean install before building or restarting the service. A current high or critical production advisory therefore blocks deployment instead of becoming a warning buried in CI output. An existing service account is accepted only when its primary group, home and non-login shell match the requested configuration and it has no supplementary groups. After provisioning, the installer executes path-access, built Hub, Prisma, an authenticated database query, `cph`, `socat` and bubblewrap namespace probes as that account with `no_new_privs` set. Inaccessible parents, UID-specific database/network policy, or a host that forbids unprivileged user namespaces therefore fail before unit installation. The default persistent layout is deliberately outside every source/release tree: ```text /var/lib/cph-hub/home /var/lib/cph-hub/state /var/lib/cph-hub/workspaces /var/cache/cph-hub ``` Custom paths are supported with `SERVICE_HOME`, `STATE_DIR`, `CACHE_DIR` and `WORKSPACE_ROOT`, but all must be absolute. The installer rejects any persistent path that is equal to, above, or below `BASE`; this keeps rsync, release switching, rollback and pruning unable to traverse customer workspaces. Set the same custom workspace in `HUB_PROJECT_WORKSPACE_ROOT` inside `platform.env`. After a successful installation: ```sh sudo systemctl start cph-hub.service sudo systemctl status cph-hub.service ``` `HOST` and `PORT` in `platform.env` are passed to the real HTTP listener. The default `127.0.0.1:8788` expects a local TLS reverse proxy; the public OAuth URL must be an HTTPS `HUB_PUBLIC_BASE_URL`.