Deployment
The SCF Controls Platform is self-hosted with Docker Compose. That is the only supported deployment, and this page is the only install guide: the repository READMEs point here.
The first half is the install, start to first sign-in. The second half is a reference for every option the operator scripts accept.
Deployment at a glance
Section titled “Deployment at a glance”| Self-hosted (Docker Compose) | |
|---|---|
| You run | Docker Compose on your own host |
| Sign-in | Your own OIDC provider, the bundled Keycloak, or none (generated API key) |
| Evidence storage | An S3-compatible store of your own set in the app, none, or the bundled MinIO for evaluation |
| SCF catalogue | You import your own licensed SCF workbook |
| Tenancy | Single-tenant per deployment |
| Licence | Software under AGPL-3.0; SCF content under CC BY-ND 4.0, not redistributed |
Requirements
Section titled “Requirements”- Docker Engine with the Compose v2 plugin (
docker compose, not the legacydocker-compose). The upgrade script also needsgit,curlandpython3on the host. - Memory. The per-container ceilings in
docker-compose.ymladd up to about 8 GB on the default install and about 10 GB with the bundled identity provider. They are ceilings, not reservations: a quiet trial uses far less, butcelery-workeralone may use up to 3 GB when it generates documents, so size the host for the total before you rely on it. - Disk for the database and evidence volumes. A fresh install is small; evidence grows with use.
- A git clone of the repository, not an unpacked archive.
scripts/upgrade.shrefuses to run outside a git checkout. - Your SCF Excel workbook (
.xlsx), downloaded from the Secure Controls Framework. The platform ships the importer, not the licensed content.
Install
Section titled “Install”-
Clone the repository and run the installer.
Terminal window git clone https://github.com/MarkAC007/scf-controls-platform-oss.gitcd scf-controls-platform-ossscripts/install.sh --upThe installer prints a one-time setup token and opens a wizard on
http://127.0.0.1:8765/. Paste the token, then answer two questions: bundled or external database, and which identity provider. It generates every other credential, writes them as0600files under~/.scf/secrets, and writes a.envholding non-secret settings only. With--upit then starts the stack and waits for the backend to report healthy.First-run setup walks through every wizard screen with screenshots, including the SSH tunnel you need when the host is remote (the wizard listens on loopback only). For scripted installs use
--unattendedwith a JSON file instead of the wizard; see Installer options below. -
Create your organisation and sign in. The steps depend on the sign-in choice you made in the wizard:
install.sh --upalready created your organisation and platform-administrator account for the email you gave the wizard, and printed Keycloak’s one-time password. Everyone you invite from the platform afterwards gets a Keycloak account and a temporary password the same way, without you touching the Keycloak console. If you missed your own:Terminal window docker compose logs idp-initSign in at
http://localhost:5173with that password. Keycloak makes you set your own.Sign-in works as soon as the stack is up, but nothing has created your organisation or made you a member of it. Run this once, with the email address exactly as your provider reports it (lower case):
Terminal window docker compose exec backend python -m cli.admin setup --admin-email you@example.comIt creates the default organisation, makes that account a member and a platform administrator, and is safe to re-run. Then sign in at
http://localhost:5173.The browser authenticates with the generated API key; there is no sign-in screen. Create the organisation once so the app has something to show:
Terminal window docker compose exec backend python -m cli.admin setupRead the trust boundary at the top of this page before this install leaves your machine.
-
Load the SCF catalogue. Once you are signed in (or straight away on the “Neither for now” path, which has no sign-in screen), a fresh install shows a one-time onboarding screen: choose your SCF
.xlsx, upload it, and the catalogue is extracted and seeded in the background. The importer reads the SCF version from the workbook, so any release imports without a code change.Prefer the command line, or installing headless? Place the workbook and run the one-shot importer instead. The backend seeds the catalogue into the database at startup when the database has none, so restart it afterwards if the stack is already running:
Terminal window mkdir -p catalog-sourcecp /path/to/SCF-2025.4.xlsx catalog-source/scf.xlsx # or set SCF_XLSX in .envdocker compose --profile init run --rm catalog-importerdocker compose restart backend
Your install is complete. From here:
- Credentials and secrets explains what the installer wrote and where.
- Identity provider covers remote-host hostnames and provider set-up in detail.
- Evidence storage below, if you want evidence in your own bucket.
- Backups and Upgrading below, before you rely on the install.
Installer options
Section titled “Installer options”scripts/install.sh accepts these flags. Every one is optional.
| Flag | What it does |
|---|---|
--up | Start the stack after provisioning, wait up to 180 s for the backend healthcheck, create the platform administrator (bundled Keycloak only) and print the one-time Keycloak password |
--unattended FILE | Provision from a JSON file with no browser. Same validation, same output as the wizard |
--import-env | Move the credentials of an existing hand-written .env into secret files. Nothing is forced: an install that keeps its credentials in .env continues to work |
--no-minio | Bundle no object store. The stack starts without MinIO and stores no evidence until you configure a bucket under Settings |
--secrets-dir DIR | Absolute path for the secrets directory (default ~/.scf/secrets) |
--port N | Wizard port on 127.0.0.1 (default 8765) |
--image IMG | Backend image to run the wizard from (default ghcr.io/markac007/scf-backend, tag from SCF_IMAGE_TAG or latest) |
-h, --help | Print the usage text |
Environment variables the installer reads:
| Variable | Purpose |
|---|---|
SCF_SECRETS_DIR | Default secrets directory, overridden by --secrets-dir |
SCF_IMAGE_TAG | Tag for the default wizard image |
SCF_DB_PASSWORD | External database password for unattended installs, passed on stdin to the container so it never lands in the JSON file or in ps output |
Behaviour worth knowing:
- It refuses to run when the checkout already has a
.env(use--import-env) or when the secrets directory already holds a.provisionedmarker. Existing credential files are never overwritten. - Five wrong setup tokens shut the wizard down with nothing written. Re-run for a fresh token.
- On Linux the secret files are made readable by group
1001, the uid the containers run as. SCF_INSTALLER_DEV_MOUNTexists for developers testing the installer from a worktree; operators never need it.
Unattended install file
Section titled “Unattended install file”install.example.json in the repository root is a commented template. The bundled path needs
three values:
{ "db": { "type": "bundled" }, "idp": { "type": "bundled_keycloak", "bootstrap_admin_email": "you@example.com" }}| Key | Values |
|---|---|
db.type | bundled or external. External adds either the discrete fields or db.dsn, and takes the password from SCF_DB_PASSWORD |
db.host, db.port, db.dbname, db.user, db.sslmode | The external database as discrete fields. sslmode defaults to require; disable also needs "allow_plaintext": true |
db.dsn | The external database as a postgresql://user@host:port/dbname?sslmode=... string instead of the discrete fields. It is parsed and rebuilt: libpq options such as options= or a unix-socket host= are refused. A password inside the string is used unless SCF_DB_PASSWORD is set |
idp.type | bundled_keycloak, external_oidc or none |
idp.bootstrap_admin_email | Required with bundled_keycloak: the account --up promotes to platform administrator. Omit it and provisioning is refused before anything is written |
idp.oidc_issuer, idp.oidc_client_id, idp.oidc_client_secret | For external_oidc. The secret is written to a file, never to .env; leave it out for a public client |
storage.type | bundled_minio (default) or none, the same choice as --no-minio. Giving both with different values is refused |
SCF_DB_PASSWORD='...' scripts/install.sh --unattended ./install.json --upWhat the installer writes
Section titled “What the installer writes”Ten credential files in the secrets directory: DB_PASSWORD, SCF_SECRET_KEY, API_KEY,
DOWNLOAD_TOKEN_SECRET, MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, KC_ADMIN_PASSWORD and OIDC_CLIENT_SECRET. None is displayed.
A .env with non-secret settings: SCF_SECRETS_DIR, COMPOSE_FILE (the base file plus the
secrets overlay), COMPOSE_PROFILES (storage, idp, or both), ENVIRONMENT=production,
OSS_SINGLE_TENANT=1, the database connection settings, EVIDENCE_STORAGE_BOOTSTRAP (plus empty
AWS_ENDPOINT_URL, EVIDENCE_BUCKET and EVIDENCE_PUBLIC_ENDPOINT on a --no-minio install), and
the identity-provider settings for the choice you made. COMPOSE_PROFILES is omitted when no
profile is needed. Never commit it, and back up the secrets directory: see
Credentials and secrets.
Service ports
Section titled “Service ports”| Service | URL / port | Remap via | Purpose |
|---|---|---|---|
| Frontend | http://localhost:5173 | FRONTEND_PORT | Web UI, served by nginx with an /api/ proxy |
| Backend API | http://localhost:8000 | BACKEND_PORT | FastAPI; interactive docs at /docs |
| MinIO (S3 API) | http://localhost:9000 | MINIO_PORT | Evidence object store, storage profile only |
| MinIO console | http://localhost:9001 | MINIO_CONSOLE_PORT | Bucket admin, storage profile only |
| Keycloak | http://localhost:8081 | KEYCLOAK_PORT | Bundled identity provider, idp profile only |
| Postgres | 127.0.0.1:5432 (loopback only) | POSTGRES_PORT | Database |
Every host-side port is remappable from .env if a default is already taken; the container side
never changes. Redis is not published at all.
Put a reverse proxy with TLS in front of the frontend before exposing it beyond your machine, and
raise its request body limit to at least 64 MB, or the SCF workbook upload fails with
413 Request Entity Too Large.
Evidence storage
Section titled “Evidence storage”The stack ships a bundled MinIO container (S3-compatible) so that a first run works with no cloud account — that is what it is for, and it is not the recommended home for real evidence. You can point the platform at cloud storage of your own instead, or install with no object store at all and configure one from the application afterwards.
The tabs below cover what the installer writes into .env. Once the platform is running, evidence
storage is configured in the application under Settings, Evidence storage: a platform
administrator sets the store for the whole installation, and each organisation may point itself at a
store of its own. Those settings are held in the database with their credentials encrypted, they
apply without a restart, and they take precedence over everything in .env. Treat the environment
variables as the floor beneath them, not the control.
Use a real S3 bucket by setting the non-secret values in .env and blanking the MinIO endpoints
(empty = talk to AWS S3 directly):
EVIDENCE_BUCKET=your-real-bucketAWS_DEFAULT_REGION=eu-west-1AWS_ENDPOINT_URL= # leave blankEVIDENCE_PUBLIC_ENDPOINT= # leave blankPut the credentials in the secrets directory, not in .env. On an installer-provisioned stack
the secrets overlay blanks AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the environment and
supplies them from files, so writing them into .env has no effect:
printf '%s\n' 'AKIA...' > "$SCF_SECRETS_DIR/AWS_ACCESS_KEY_ID"printf '%s\n' '...' > "$SCF_SECRETS_DIR/AWS_SECRET_ACCESS_KEY"chmod 0600 "$SCF_SECRETS_DIR/AWS_ACCESS_KEY_ID" "$SCF_SECRETS_DIR/AWS_SECRET_ACCESS_KEY"docker compose up -d --force-recreate backend celery-worker celery-beatSetting them in .env is the legacy path, and only works on an install that has no
SCF_SECRETS_DIR. Prefer an IAM role on the host where you can have one — then neither file is
needed.
Install with --no-minio to bring up the platform with no object store at all:
./scripts/install.sh --no-minio --upThe minio and minio-init services never start — docker compose config --services does not even
list them — and .env gets no storage profile. The backend, both Celery services and the frontend
come up healthy; evidence uploads and downloads are refused with a clear error until storage is
configured, and the platform reports itself as having none rather than pointing at an address where
nothing is listening.
Use this when you already have an S3-compatible bucket. Configure it afterwards from the first tab above, or per organisation from the Settings screen.
This is what you get if you do nothing, and it is the right choice only while you are evaluating —
read the end-of-life warning above before you put real evidence here. docker-compose.yml ships a
minio service and a one-shot minio-init that creates the evidence bucket on first boot.
Both services sit behind the storage compose profile, and the installer writes that profile
into .env for you:
COMPOSE_PROFILES=storage # or idp,storage when the bundled Keycloak is on tooEVIDENCE_STORAGE_BOOTSTRAP=bundled_minioRemove storage from that line and the object store stops starting — nothing else in the stack
depends on it, so the rest comes up healthy and evidence operations are refused with an error
instead. EVIDENCE_STORAGE_BOOTSTRAP is a separate signal because COMPOSE_PROFILES is read by the
docker CLI on your host and never reaches a container.
The endpoints stay at their defaults and need no line in .env:
AWS_ENDPOINT_URL=http://minio:9000 # internal endpoint (backend/worker)EVIDENCE_PUBLIC_ENDPOINT=http://localhost:9000 # browser-facing presigned URLsEVIDENCE_BUCKET=evidenceMINIO_ROOT_USER and MINIO_ROOT_PASSWORD are not among them on an installed stack.
scripts/install.sh generates both into SCF_SECRETS_DIR as 0600 files, and the secrets overlay
blanks the environment variables and passes MINIO_ROOT_USER_FILE / MINIO_ROOT_PASSWORD_FILE
instead — so the MinIO password never appears in .env or in docker inspect. You only set them by
hand on a legacy .env install, where changeme-minio-password is a placeholder you must replace.
The application does not use that root pair. minio-init creates a MinIO user from
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY and attaches a policy covering the evidence bucket and
nothing else — no administrative action, no second bucket — so a leaked application credential does
not carry the object store with it. It re-runs on every docker compose up and makes no change when
the account is already correct. On an install provisioned before the scoped account existed, the two
pairs are the same value; minio-init says so in its log and leaves them alone. See
Secrets for how to migrate one.
Azure Blob is no longer an evidence storage backend. AZURE_STORAGE_ACCOUNT_NAME,
AZURE_STORAGE_ACCOUNT_KEY and EVIDENCE_CONTAINER are read only to warn: the backend logs that the
setting is retired and being ignored, then uses whatever store it would have used had the variables
been absent.
Use the S3 path above, which covers Amazon S3, Google Cloud Storage over its S3-compatible API, MinIO and any other S3-compatible store, and which is the only path that can be configured per organisation.
Identity provider
Section titled “Identity provider”The installer writes the identity-provider settings for you. Choosing Bundled Keycloak adds the
idp compose profile, so docker compose up -d starts Keycloak plus two one-shot containers,
keycloak-schema-init (creates Keycloak’s schema in the bundled Postgres) and idp-init
(configures the realm), alongside the stack; choosing your own provider writes the OIDC_* variables and
starts nothing extra. Both set VITE_OIDC_ENABLED=true, which is baked into the frontend at build
time.
With the bundled profile the backend, celery-worker and celery-beat services also receive
KC_ADMIN_USER and KC_ADMIN_PASSWORD, which is what lets an invite create the Keycloak account
rather than only send a link. On your own provider those stay blank and invites are unchanged —
your provider owns the account. See
Provisioning invited users.
Deploying on a host other than the one you browse from needs the public hostnames in .env
(KC_HOSTNAME, OIDC_ISSUER, OIDC_REDIRECT_URI) and the containers that read them re-created
with docker compose up -d. None of the three is a build argument, so no frontend rebuild is
needed, but docker compose restart does not reload .env. The
Identity provider page covers that, the ISSUER-versus-DISCOVERY
distinction, user provisioning and troubleshooting.
Single-tenant mode
Section titled “Single-tenant mode”A self-hosted deployment is single-tenant: one organisation, administered by the master API key
when no identity provider is enabled. OSS_SINGLE_TENANT=1 is fail-closed: at startup the backend
refuses to honour it if it finds more than one organisation or more than one human member. The stack
also ships ENVIRONMENT=production; set development only on a trusted local machine, never on a
network-exposed host.
Upgrading
Section titled “Upgrading”scripts/upgrade.sh is the supported upgrade path. The target tag is required; take it from the
releases page:
scripts/upgrade.sh vX.Y.ZIt stops the backend and workers (Postgres stays up, so expect a short outage), backs up both data
stores before touching the code (a validated pg_dump and a tar of the bundled evidence volume),
checks out the tag, rebuilds, runs the migrations as an explicit one-shot, verifies the running
code, and rolls back on failure. Copy the pre-upgrade set out of ./backups if you want to keep it
beyond the retention window of scripts/backup.sh.
| Option | What it does |
|---|---|
--manifest FILE | Use a local upgrade-manifest.json instead of fetching it (air-gapped hosts) |
--yes, -y | Skip the pre-upgrade confirmation |
--rollback TIMESTAMP | Restore from the backup set taken at that timestamp: always the database (the live one is kept as <db>_failed_<timestamp>), the bundled evidence volume if the set has a MinIO tar, and the credential files if the set has secrets-<timestamp>.tar.gz (only backup.sh writes one); then check out the recorded git ref and rebuild. Reads ./backups only |
--resume-post-checkout TIMESTAMP | Run only the second half of an upgrade — rebuild, migrate, start, verify, and this release’s own .env fixups — against the code already checked out here, using the backup set at that timestamp as its rollback point. The script uses this internally (see below); you only run it by hand if a run tells you to |
--help | Print the usage text |
The catalogue directory’s group is re-checked on every upgrade
Section titled “The catalogue directory’s group is re-checked on every upgrade”webclient/public/data is a host directory bind-mounted into the backend and the catalogue
importer at /app/data/json, and the import writes into it as gid SCF_APP_GID (1001 by
default) — never as you. scripts/install.sh sets that up; nothing re-checked it afterwards,
so an install that was correct could reach a failing catalogue import with no warning in
between (the same failure class as OSS #98/#99).
Phase 4 of scripts/upgrade.sh now checks it after the checkout and before anything starts. On a non-Linux host it prints that it is skipping and does nothing. On Linux it reads
SCF_APP_GID from .env (falling back to 1001) and compares:
[upgrade] webclient/public/data is already gid 1001 mode 2775 — nothing to do.If it has drifted, the warning names what was actually there before the fix runs — the absence of that signal is what made the original issues hard to diagnose — and the result is confirmed by re-reading the directory:
[warn] webclient/public/data is '4242 755', but the catalogue import needs gid 1001 mode 2775 (a checkout, or an install provisioned by hand, can leave it wrong) — fixing.[upgrade] granting gid 1001 write access to webclient/public/data (catalogue output)[ok] webclient/public/data is gid 1001 mode 2775 — setgid, so imported JSON keeps group 1001.It is a chgrp, never a chown, and it never widens modes past 2775/0664. A failure here
warns and continues rather than rolling back — a migrated, verified database must not be
reverted over host file permissions — so if you see that warning, run the command it prints and
re-run the catalogue importer.
The script hands over to the release it is installing
Section titled “The script hands over to the release it is installing”Halfway through, scripts/upgrade.sh replaces itself with the target release’s copy and carries on
there. Bash reads a script into memory before running it, so without this the second half of every
upgrade would execute the pre-upgrade release’s instructions, and any step a release added to the
upgrade procedure would be skipped on the one upgrade that introduces it.
Every run says which path it took, on the line straight after Checked out vX.Y.Z. Either the
release changed the script and the run hands over:
[upgrade] vX.Y.Z changed scripts/upgrade.sh (sha256 3f9a1c2b8e04… -> c71d5ea90b33…) — re-exec'ing it so vX.Y.Z's own upgrade steps run.[upgrade] exec bash scripts/upgrade.sh --resume-post-checkout (backup 20260913_120000, target X.Y.Z)or it did not, and the run says so just as plainly:
[upgrade] vX.Y.Z ships a byte-identical scripts/upgrade.sh (sha256 c71d5ea90b33…) — no re-exec needed.Nothing about the procedure changes for you: same command, same confirmation, same backup set, and a failure after the hand-over still rolls back automatically to the backup taken before it. If the hand-over cannot happen, the run finishes and then tells you so, along with the one command to apply the steps that were missed.
Read UPGRADING.md in the repository before each upgrade for release-specific notes. An upgrade
does not touch the catalogue: it lives in the database, and the start-up seeder only fills empty
tables. To load a newer SCF workbook, upload it under Platform, Catalog (staged, diffed, then
applied) as described in Platform Catalog Upgrade.
Backups
Section titled “Backups”scripts/backup.sh backs up a live stack without stopping it:
scripts/backup.shscripts/backup.sh --quiesce # pause the writers for a strict point-in-time setEach run writes a set into ./backups: a pg_dump of the database, a tar of the bundled evidence
volume (bundled store only), a tarball of the secrets directory, the git ref, and a checksum file.
Retention is controlled by environment variables:
| Variable | Default | Meaning |
|---|---|---|
BACKUPS_DIR | ./backups | Where sets are written |
KEEP_N | 7 | Always keep at least this many newest sets (0 to ignore) |
KEEP_DAYS | 30 | Delete older sets beyond KEEP_N after this many days (0 to ignore) |
Evidence held in your own bucket is outside both scripts; protect it with your provider’s own tooling. See Backup and restore for restore procedures.
Checking a running install
Section titled “Checking a running install”scripts/verify-prod-build.sh is a read-only smoke test of a running frontend: it confirms you are
serving a production build with its security headers.
scripts/verify-prod-build.sh http://localhost:5173The API is healthy when http://localhost:8000/health returns a healthy status, and
docker compose ps shows backend as healthy.
Troubleshooting
Section titled “Troubleshooting”See Troubleshooting for the full list. The most common first-deploy issues:
| Symptom | Cause | Fix |
|---|---|---|
| The app loads but shows no organisation | Nothing has created one yet | Run the cli.admin setup command for your sign-in choice, step 2 under Install |
| Everything is empty after sign-in | Catalogue not imported yet | Upload the workbook from the onboarding screen, or run the headless importer and then docker compose up -d so the backend seeds it |
Containers exit at start (Postgres has no password, minio-init is FATAL, backend refuses to start without an API key) | The stack was started without running the installer, so there is no .env and no secret files | Run scripts/install.sh. If the checkout already has a hand-written .env, run scripts/install.sh --import-env instead |
| Sign-in page returns 502 with the bundled Keycloak | idp profile not active | Check COMPOSE_PROFILES in .env includes idp, then docker compose up -d |
| Evidence upload fails | No object store configured | Configure one under Settings, Evidence storage, or check the Evidence storage settings |
| Workbook upload fails with 413 | Reverse proxy body limit | Raise the proxy’s limit to at least 64 MB |
