Skip to content

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.

Self-hosted (Docker Compose)
You runDocker Compose on your own host
Sign-inYour own OIDC provider, the bundled Keycloak, or none (generated API key)
Evidence storageAn S3-compatible store of your own set in the app, none, or the bundled MinIO for evaluation
SCF catalogueYou import your own licensed SCF workbook
TenancySingle-tenant per deployment
LicenceSoftware under AGPL-3.0; SCF content under CC BY-ND 4.0, not redistributed

  • Docker Engine with the Compose v2 plugin (docker compose, not the legacy docker-compose). The upgrade script also needs git, curl and python3 on the host.
  • Memory. The per-container ceilings in docker-compose.yml add 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, but celery-worker alone 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.sh refuses 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.

  1. Clone the repository and run the installer.

    Terminal window
    git clone https://github.com/MarkAC007/scf-controls-platform-oss.git
    cd scf-controls-platform-oss
    scripts/install.sh --up

    The 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 as 0600 files under ~/.scf/secrets, and writes a .env holding non-secret settings only. With --up it 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 --unattended with a JSON file instead of the wizard; see Installer options below.

  2. Create your organisation and sign in. The steps depend on the sign-in choice you made in the wizard:

    install.sh --up already 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-init

    Sign in at http://localhost:5173 with that password. Keycloak makes you set your own.

  3. 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-source
    cp /path/to/SCF-2025.4.xlsx catalog-source/scf.xlsx # or set SCF_XLSX in .env
    docker compose --profile init run --rm catalog-importer
    docker compose restart backend

Your install is complete. From here:


scripts/install.sh accepts these flags. Every one is optional.

FlagWhat it does
--upStart 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 FILEProvision from a JSON file with no browser. Same validation, same output as the wizard
--import-envMove 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-minioBundle no object store. The stack starts without MinIO and stores no evidence until you configure a bucket under Settings
--secrets-dir DIRAbsolute path for the secrets directory (default ~/.scf/secrets)
--port NWizard port on 127.0.0.1 (default 8765)
--image IMGBackend image to run the wizard from (default ghcr.io/markac007/scf-backend, tag from SCF_IMAGE_TAG or latest)
-h, --helpPrint the usage text

Environment variables the installer reads:

VariablePurpose
SCF_SECRETS_DIRDefault secrets directory, overridden by --secrets-dir
SCF_IMAGE_TAGTag for the default wizard image
SCF_DB_PASSWORDExternal 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 .provisioned marker. 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_MOUNT exists for developers testing the installer from a worktree; operators never need it.

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" }
}
KeyValues
db.typebundled 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.sslmodeThe external database as discrete fields. sslmode defaults to require; disable also needs "allow_plaintext": true
db.dsnThe 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.typebundled_keycloak, external_oidc or none
idp.bootstrap_admin_emailRequired 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_secretFor external_oidc. The secret is written to a file, never to .env; leave it out for a public client
storage.typebundled_minio (default) or none, the same choice as --no-minio. Giving both with different values is refused
Terminal window
SCF_DB_PASSWORD='...' scripts/install.sh --unattended ./install.json --up

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.


ServiceURL / portRemap viaPurpose
Frontendhttp://localhost:5173FRONTEND_PORTWeb UI, served by nginx with an /api/ proxy
Backend APIhttp://localhost:8000BACKEND_PORTFastAPI; interactive docs at /docs
MinIO (S3 API)http://localhost:9000MINIO_PORTEvidence object store, storage profile only
MinIO consolehttp://localhost:9001MINIO_CONSOLE_PORTBucket admin, storage profile only
Keycloakhttp://localhost:8081KEYCLOAK_PORTBundled identity provider, idp profile only
Postgres127.0.0.1:5432 (loopback only)POSTGRES_PORTDatabase

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.


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

Terminal window
EVIDENCE_BUCKET=your-real-bucket
AWS_DEFAULT_REGION=eu-west-1
AWS_ENDPOINT_URL= # leave blank
EVIDENCE_PUBLIC_ENDPOINT= # leave blank

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

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

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


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.


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.


scripts/upgrade.sh is the supported upgrade path. The target tag is required; take it from the releases page:

Terminal window
scripts/upgrade.sh vX.Y.Z

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

OptionWhat it does
--manifest FILEUse a local upgrade-manifest.json instead of fetching it (air-gapped hosts)
--yes, -ySkip the pre-upgrade confirmation
--rollback TIMESTAMPRestore 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 TIMESTAMPRun 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
--helpPrint 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.


scripts/backup.sh backs up a live stack without stopping it:

Terminal window
scripts/backup.sh
scripts/backup.sh --quiesce # pause the writers for a strict point-in-time set

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

VariableDefaultMeaning
BACKUPS_DIR./backupsWhere sets are written
KEEP_N7Always keep at least this many newest sets (0 to ignore)
KEEP_DAYS30Delete 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.


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.

Terminal window
scripts/verify-prod-build.sh http://localhost:5173

The API is healthy when http://localhost:8000/health returns a healthy status, and docker compose ps shows backend as healthy.


See Troubleshooting for the full list. The most common first-deploy issues:

SymptomCauseFix
The app loads but shows no organisationNothing has created one yetRun the cli.admin setup command for your sign-in choice, step 2 under Install
Everything is empty after sign-inCatalogue not imported yetUpload 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 filesRun 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 Keycloakidp profile not activeCheck COMPOSE_PROFILES in .env includes idp, then docker compose up -d
Evidence upload failsNo object store configuredConfigure one under Settings, Evidence storage, or check the Evidence storage settings
Workbook upload fails with 413Reverse proxy body limitRaise the proxy’s limit to at least 64 MB