Credentials and secrets
The platform’s goal is that no human ever types, stores, copies or synchronises a credential.
scripts/install.sh generates everything that can be generated and asks only for what a person
can actually know. This page describes where each credential ends up and what to do with it
afterwards.
The three tiers
Section titled “The three tiers”Every credential the platform consumes falls into exactly one tier.
| Tier | Who supplies it | Where it lives | Example |
|---|---|---|---|
| 1 — Bootstrap | Generated (bundled database) or collected by the installer (external database) | 0600 file on disk | DB_PASSWORD, SCF_SECRET_KEY |
| 2 — Machine entropy | Always generated. No person ever sees or types these | 0600 file on disk | API_KEY, MINIO_ROOT_PASSWORD |
| 3 — External integrations | You, in the app, whenever you want the feature | Database, encrypted | RESEND_API_KEY, ANTHROPIC_API_KEY |
Tiers 1 and 2 have to be on disk. The platform must reach its own database and decrypt its own configuration after an unattended reboot, so anything that removes them requires a person to unseal the platform on every restart. Tier 3 is different: none of it is needed to boot, each item gates one optional feature, and it is managed from Settings, Integrations.
The secrets directory
Section titled “The secrets directory”scripts/install.sh writes one file per credential into a directory outside the checkout, so a
git pull or an image rebuild can never clobber or expose it.
- The directory path is recorded as
SCF_SECRETS_DIRin.env, as an absolute path. Every later consumer reads that value; the installer’s$HOMEis consulted once, at install time. - The directory is
0700, owned by the operator who ran the installer. - Each file is
0600, named exactly after the credential, and contains just the value. A trailing newline is fine (every reader strips whitespace). An empty file means “not set”. .provisionedis the provisioning sentinel. It is what makes provisioning once-only. Do not delete it..provision-tokenauthorises the install wizard and is deleted once provisioning succeeds.
Files written on the bundled path:
DB_PASSWORD API_KEY MINIO_ROOT_USERSCF_SECRET_KEY DOWNLOAD_TOKEN_SECRET MINIO_ROOT_PASSWORDKC_ADMIN_PASSWORD OIDC_CLIENT_SECRET AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEYAWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are the application’s own credential and are
generated independently of MINIO_ROOT_USER / MINIO_ROOT_PASSWORD. On the bundled path the
minio-init one-shot creates a MinIO user for the application pair and attaches a policy naming the
evidence bucket and nothing else: no administrative action, no second bucket. The root pair
administers the object store and is never used by the application.
All ten files are written on both paths. A --no-minio install writes the four storage names
empty, which is how “not set” is spelled here.
The reason to write a file rather than omit it is not Compose: a missing secret file is only a
warning (secret file <name> does not exist, measured on Compose v5.4.0, docker compose config
exits 0 and create proceeds). It is that the services fail open on an absent file. MinIO’s
server binary reads MINIO_ROOT_USER_FILE itself and, finding nothing there, boots the object store
on its built-in minioadmin:minioadmin — a working stack with default credentials and no error
line. The entrypoint guard in docker-compose.yml exists for exactly that case and refuses to start
on an empty or missing credential, so the empty file is what makes the refusal reliable rather
than accidental.
The compose overlay
Section titled “The compose overlay”File-backed credentials arrive through an opt-in overlay, docker-compose.secrets.yml, never
through the base docker-compose.yml. The reason is that docker compose config validates every
file: path in a secrets: block, and scripts/upgrade.sh gates on compose config -q — so
putting the block in the base file would break the upgrade path for every install that has no
secrets directory.
The installer records the overlay in .env:
SCF_SECRETS_DIR=/home/scf/.scf/secretsCOMPOSE_FILE=docker-compose.yml:docker-compose.secrets.ymldocker compose reads COMPOSE_FILE from .env itself, so plain docker compose up -d picks up
both files with no extra flags. scripts/upgrade.sh and scripts/backup.sh read the same value,
so they operate on the same file set the stack was brought up from.
Linux: the files are group-readable by gid 1001
Section titled “Linux: the files are group-readable by gid 1001”A 0600 file owned by the operator is unreadable to the backend and Celery containers, which run
as uid 1001. On Linux the installer therefore group-owns the directory to 1001 and relaxes the
group bit, and prints what it did:
docker run --rm -v "$SCF_SECRETS_DIR:/s" alpine:3 \ sh -c 'chgrp -R 1001 /s && chmod 0750 /s && chmod 0640 /s/*'The files stay owner-only-writable. Docker Desktop on macOS remaps bind-mount ownership, so it does not need this and the installer skips it.
1001 is the gid of the apiuser account baked into the backend image. It is configurable as
SCF_APP_GID; the installer records the value it used in .env, and the compose files read it
from there. Change it only if gid 1001 is already taken on your host by something you would
rather not have reading the directory, and re-run the installer afterwards — editing the .env
line by itself leaves the files on the old gid.
Not every service reads the files the same way
Section titled “Not every service reads the files the same way”Four of the services that read a secret file are not uid 1001, so group-read alone does not
reach them. Each satisfies the check differently, and the difference matters the moment you
harden one of them:
| Service | How it reads a 0640 secret |
|---|---|
backend, celery-worker, celery-beat | Run as uid/gid 1001 — the group bit is a direct match |
postgres, keycloak-schema-init | Keep CAP_DAC_OVERRIDE via cap_add, so container root bypasses the mode check |
minio, minio-init, keycloak, idp-init | Container root with cap_drop: ALL — no CAP_DAC_OVERRIDE, and no matching uid or gid. They are given SCF_APP_GID as a supplementary group (group_add) |
External secret managers
Section titled “External secret managers”The file tier is the integration seam. Vault Agent, the AWS and Azure CSI drivers,
sops exec-file and systemd LoadCredential all materialise secrets as files. Point
SCF_SECRETS_DIR at wherever your tool writes them, with the file names above, and the platform
reads them like any other. No vendor adapter ships with the platform and none is needed.
The resolution order for any credential is database, then NAME_FILE, then the plain
environment variable. The environment tier is unchanged from earlier releases, which is what
keeps existing installs working.
Rotation
Section titled “Rotation”SCF_SECRET_KEY
Section titled “SCF_SECRET_KEY”SCF_SECRET_KEY encrypts every tier-3 credential. It accepts a comma-separated list: the first
entry is the primary key used for new writes, and the rest stay available for decryption. That is
what makes rotation safe.
-
Prepend a new key, keeping the old one:
Terminal window NEW=$(docker compose run --rm --no-deps --entrypoint python backend \-c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')# SCF_SECRET_KEY=<new>,<old> -
Restart so the backend and workers pick up the list.
-
Re-encrypt everything under the new primary:
Terminal window docker compose exec backend python -m cli.admin rotate-secret-keyIt prints per-table counts and exits non-zero if any row still decrypts only under a non-primary key.
-
Only once it reports zero remaining, drop the old key from the comma list and restart again.
Legacy plaintext rows
Section titled “Legacy plaintext rows”Installs that predate encryption have plaintext webhook secrets and invite tokens. They keep working — reads fall back to plaintext — but you should convert them:
docker compose exec backend python -m cli.admin backfill-encryptdocker compose exec backend python -m cli.admin secrets-statusBoth are idempotent. secrets-status prints the health view and never prints a value.
The database password
Section titled “The database password”Rotating DB_PASSWORD is a manual, three-step procedure, because POSTGRES_PASSWORD_FILE is read
only at initdb time and has no effect on a database that already exists:
docker compose exec postgres psql -U cg -d cg_scf -c "ALTER ROLE cg WITH PASSWORD 'new-value';"printf 'new-value\n' > "$SCF_SECRETS_DIR/DB_PASSWORD" && chmod 0600 "$SCF_SECRETS_DIR/DB_PASSWORD"docker compose up -d --force-recreate backend celery-worker celery-beat keycloakChange the role first. Writing the file first leaves the services unable to authenticate.
