Skip to main content
Run custoscp serve as a systemd service, with secrets held in systemd’s encrypted credential store instead of a plaintext env file. The unit template is the unit file below. The control plane reads all config from environment variables (see Configuration for the full contract). Non-secret values go in the unit as Environment=; secrets are encrypted at rest and decrypted into a per-service tmpfs at runtime.

1. Service user

The unit runs as a dedicated locked system user. Missing it is the status=217/USER failure — systemd aborts before ExecStart.

2. Generate keys

gen-keys prints an env-format bundle. Redirect to a root-only file so it never hits terminal scrollback or shell history:
The bundle contains:
Do everything below as root (the bundle and credstore are root-only).

3. Encrypt secrets into the credential store

The idea is the same for every secret: put it in a file, encrypt the file, delete the file. systemd-creds encrypts with a host/TPM key only root can access. Each output file in /etc/credstore.encrypted/ is found by its filename, which must match the credential ID in the unit. First make the store folder:
The three CP keys are already in the bundle as NAME=value lines. This loop grabs each value (the part after =) and encrypts it:
The DB URL and Resend key aren’t in the bundle — put each in its own file with an editor, then encrypt that file:
Then delete every plaintext file:
Notes:
  • Secrets go into files, never onto a command line, so nothing lands in shell history or ps. After shred, the plaintext exists only in the running service’s memory.
  • A trailing newline from the editor is harmless: the service reads each value with $(cat ...), which strips it.
Gotchas that cost time:
  • --name = credential ID = filename. Mismatch fails at service start.
  • Run systemd-creds as root (sudo). Without it you get InteractiveAuthenticationRequired; if you instead put sudo on the wrong command in a pipe (e.g. sudo echo ... | systemd-creds), only echo is root and systemd-creds still fails.
The .cred files are ciphertext, tied to this host’s key; they do not decrypt elsewhere.

4. Database migrations

migrate reads CUSTOS_DATABASE_URL. Decrypt it from the store straight into the command’s environment — never onto the command line (which would hit history and /proc/<pid>/cmdline):
History records the decrypt call, not the URL; the value enters only custoscp’s environment. Run this once before first start and after upgrades that ship migrations. (Supabase on an IPv4-only host: use the session-pooler URI; the direct endpoint is IPv6-only.)

5. First admin

Same pattern:

6. Install and start

Control-plane upgrades are documented separately in Upgrading the control plane.

Per-deployment values

Edit these in the unit before installing:
  • CUSTOS_APP_URL, CUSTOS_CORS_ORIGINS — the public frontend origin.
  • CUSTOS_EMAIL_FROM — a Resend-verified sender.
  • CUSTOS_LISTEN_ADDR — loopback; host Nginx terminates TLS and proxies here.
  • OTEL_EXPORTER_OTLP_ENDPOINT — Alloy’s loopback OTLP receiver (http://127.0.0.1:4318). Because the CP runs on the host, 127.0.0.1 reaches Alloy’s published port. A containerized CP would use http://alloy:4318 on the telemetry network instead. See the observability directory.

Rotating a secret

Put the new value in a file, re-encrypt over the same credential, then restart:
If a key was ever exposed on a terminal or command line, rotate it rather than trusting scrollback/history cleanup.

The unit file

Install this to /etc/systemd/system/custoscp.service and edit the domain values in the Environment= lines.