Skip to content

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.

Every credential the platform consumes falls into exactly one tier.

TierWho supplies itWhere it livesExample
1 — BootstrapGenerated (bundled database) or collected by the installer (external database)0600 file on diskDB_PASSWORD, SCF_SECRET_KEY
2 — Machine entropyAlways generated. No person ever sees or types these0600 file on diskAPI_KEY, MINIO_ROOT_PASSWORD
3 — External integrationsYou, in the app, whenever you want the featureDatabase, encryptedRESEND_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.

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_DIR in .env, as an absolute path. Every later consumer reads that value; the installer’s $HOME is 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”.
  • .provisioned is the provisioning sentinel. It is what makes provisioning once-only. Do not delete it.
  • .provision-token authorises the install wizard and is deleted once provisioning succeeds.

Files written on the bundled path:

DB_PASSWORD API_KEY MINIO_ROOT_USER
SCF_SECRET_KEY DOWNLOAD_TOKEN_SECRET MINIO_ROOT_PASSWORD
KC_ADMIN_PASSWORD OIDC_CLIENT_SECRET AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY

AWS_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.

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:

Terminal window
SCF_SECRETS_DIR=/home/scf/.scf/secrets
COMPOSE_FILE=docker-compose.yml:docker-compose.secrets.yml

docker 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:

Terminal window
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:

ServiceHow it reads a 0640 secret
backend, celery-worker, celery-beatRun as uid/gid 1001 — the group bit is a direct match
postgres, keycloak-schema-initKeep CAP_DAC_OVERRIDE via cap_add, so container root bypasses the mode check
minio, minio-init, keycloak, idp-initContainer root with cap_drop: ALLno CAP_DAC_OVERRIDE, and no matching uid or gid. They are given SCF_APP_GID as a supplementary group (group_add)

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.

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.

  1. 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>
  2. Restart so the backend and workers pick up the list.

  3. Re-encrypt everything under the new primary:

    Terminal window
    docker compose exec backend python -m cli.admin rotate-secret-key

    It prints per-table counts and exits non-zero if any row still decrypts only under a non-primary key.

  4. Only once it reports zero remaining, drop the old key from the comma list and restart again.

Installs that predate encryption have plaintext webhook secrets and invite tokens. They keep working — reads fall back to plaintext — but you should convert them:

Terminal window
docker compose exec backend python -m cli.admin backfill-encrypt
docker compose exec backend python -m cli.admin secrets-status

Both are idempotent. secrets-status prints the health view and never prints a value.

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:

Terminal window
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 keycloak

Change the role first. Writing the file first leaves the services unable to authenticate.