Conformiti
ProductEditionsPricing ConsultingSelf-hostDocs Security GitHub Book a demo

Self-hosting

Your servers.
Your evidence.
One command.

Conformiti is MIT licensed and designed to be run by the organisation that uses it. There is no licence server, no telemetry, no phone-home, and no feature that needs our servers. (The bundled UI does load its two webfonts from Google Fonts: self-host them and drop the two fonts.* entries from the CSP if that matters to you.)

This page is the short version. The authoritative documents ship in the repository: INSTALL.md, PREREQUISITES.md and .env.example.

Docker · recommended
$ git clone https://github.com/dboudreau00/Conformiti.git
$ cd Conformiti
$ docker compose up -d --build

PostgreSQL, Redis, the API, the reminder worker and nginx come up together, and no .env is required. The installation is empty: create your first account before you open http://localhost:8080.

shell
$ docker compose exec backend python manage.py createsuperuser
Want the worked example instead? Start the first time with SEED_DEMO_DATA=true for a seeded organisation and five personas sharing one password, generated on first boot and printed once in the backend log. Those accounts carry superuser rights and no second factor, so an installation holding them says so on its own sign-in page. Retire them with remove_demo_data before any real data goes in. The full sequence is in the first-run checklist.

What you need

A modest VM is enough

Docker path

Docker Engine 24+ with the Compose plugin. 2 vCPU / 4 GB RAM and about 3 GB of disk for images and data is comfortable for tens of users; add whatever your evidence library needs on top.

Local path

Python 3.11-3.14 and Node 20.19+ or 22.12+. SQLite and console email, no services to run. For a laptop trial or development.

Production

PostgreSQL 16, Redis 7, a TLS-terminating proxy in front, an SMTP or SES sender, and a backup target. All of it is standard.

Optional

Amazon S3 for evidence, ClamAV for upload scanning, an OIDC or SAML identity provider, a Slack or Teams incoming webhook, Jira Cloud.

Three ways in

Pick the one that matches your intent.

Recommended

Docker Compose

Everything, wired, with DEBUG off and a generated secret key. Nothing is seeded: the demo dataset is opt-in with SEED_DEMO_DATA=true.

compose
$ docker compose up -d --build
$ docker compose ps
$ docker compose logs -f backend
  • DEBUG off
  • A unique secret key generated and persisted on first boot
  • Rate limits in Redis, shared across workers
  • Only nginx published to the network; the API also mapped to the host's loopback on 127.0.0.1:8000 for debugging
  • Named volumes for pgdata, media, static, secrets and the tree
Guided

The install script

The same thing, but it waits for the stack to report healthy and prints the URLs.

macOS · Linux · WSL
$./install.sh --docker
# or, without Docker:
$./install.sh
# validator, backend suite, frontend build:
$./install.sh --test
Windows PowerShell
$.\install.ps1 -Docker
$.\install.ps1 -Test
Trial / dev

Local, no Docker

SQLite and console email. Nothing to install beyond Python and Node; nothing left running afterwards.

local
$./install.sh
# backend  → 127.0.0.1:8000
# frontend → 127.0.0.1:5173
# mail printed to stdout

Good for evaluating the product and reading the code. Not a production posture: no Redis-backed throttling, no worker, and SQLite will not enjoy your audit trail at scale.

What comes up

Five containers,
five volumes,
one port.

Only nginx is published. The API listens on loopback inside its own container and is unreachable except through the proxy, which also carries the CSP, the security headers and the 32 MB body cap.

  • db: PostgreSQL 16, on the pgdata volume
  • redis: cache, throttle counters and the Celery broker
  • backend: gunicorn serving Django, health-checked at /api/health/
  • worker: Celery worker and beat: the daily reminder scan, vendor and PBC scans, readiness snapshots, token pruning, the scanner watch
  • frontend: nginx serving the built SPA and Django's /static, proxying /api and /admin, and releasing evidence from the media volume only on an X-Accel redirect the API has already authorised

Topology

browser
  │:8080
  ▼
nginx ─┬─▶ gunicorn (127.0.0.1:8000) ─┬─▶ PostgreSQL
       │        ▲ /api/health/       └─▶ Redis
       ├─ /static, /media  (volumes)
       └─ CSP · security headers · 32 MB cap

celery worker + beat ──▶ Redis · PostgreSQL · email

volumes: pgdata · media · static · secrets · tree
secrets holds DJANGO_SECRET_KEY_FILE and the
package signing key: back both of them up.
Never add Content-Disposition in an X-Accel location. nginx passes the upstream header through, so adding one produces two, and browsers refuse the response. The API owns that header. This is called out because it is a real bug we shipped once and fixed in 0.5.0; if you customise the nginx config, do not reintroduce it.

Do this before real data

First-run
checklist.

Prefix each command with docker compose exec backend python on the Docker path, or ../.venv/bin/python from backend/ locally.

Create your administrator

Nothing is seeded, so this is the account you sign in with. Set DJANGO_SUPERUSER_USERNAME and DJANGO_SUPERUSER_PASSWORD before the first boot to have it made then instead.

shell
$ docker compose exec backend python manage.py createsuperuser

Retire the demo accounts and data

If you started with SEED_DEMO_DATA=true, the five demo personas (admin, mia, owen, aria, val) share one password, generated on first boot and printed once in the backend log. remove_demo_data deactivates them and removes the seeded documents, risks, vendors and grants; --delete removes the accounts outright.

shell
$ docker compose exec backend python manage.py remove_demo_data
# or, to delete rather than deactivate:
$ docker compose exec backend python manage.py remove_demo_data --delete

Put your real hostname in the config

The moment you leave localhost: DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, and PUBLIC_URL (which is what the vendor-questionnaire links are built from).

Terminate TLS in front and tell the app

Set BEHIND_TLS=true once a TLS proxy sits in front of nginx. That turns on secure cookies, the HTTPS redirect and the __Host- cookie prefixes. Cookie transport is the default since 0.6.1.

Back up the secrets volume

It holds DJANGO_SECRET_KEY_FILE and the package signing key. Losing the signing key does not invalidate signatures already issued: the public key travels in each bundle, but you will not be able to sign with the same identity again, and roll-forward chains will change key.

Configuration

Everything is environment-driven.

The compose file carries production-safe defaults; .env overrides them. Every key is documented in .env.example. These are the ones that matter most.

Key environment variables
SettingPurpose
Core
DJANGO_DEBUGfalse in Docker by default; true only on the local development path. The app refuses to boot in an obviously unsafe combination.
DJANGO_SECRET_KEY
DJANGO_SECRET_KEY_FILE
A strong key, or a path where one is generated and persisted. Compose uses the file form on the secrets volume.
DJANGO_ALLOWED_HOSTS
CSRF_TRUSTED_ORIGINS
CORS_ALLOWED_ORIGINS
Your real hostnames once you leave localhost. Getting these wrong is the most common cause of a working install that refuses logins.
BEHIND_TLStrue when a TLS-terminating proxy is in front: secure cookies, HTTPS redirect, __Host-/__Secure- prefixes.
PUBLIC_URLThe base the vendor-questionnaire links are built from. Without it the request's Origin is used, which a dev proxy will rewrite.
ORGANISATION_NAMEYour name as it appears in outbound email and on the questionnaire page a vendor sees.
Data and mail
DATABASE_URLPostgreSQL in Docker and production; SQLite locally. PostgreSQL 16 is what CI tests against.
EMAIL_PROVIDERconsole · smtp · mailbox (a standard IMAP/POP3 + SMTP account, with a copy filed in Sent) · ses. Test with manage.py test_mailbox --to you@example.com.
REVIEW_SCAN_HOUR
REVIEW_ALERT_LEAD_DAYS
When the daily reminder scan runs, and how far ahead it warns. Defaults to 30, 14, 7 and 1 days, plus one overdue notice.
S3_*Optional Amazon S3 for evidence storage instead of the local filesystem.
Identity
OIDC_*Issuer, client id and secret, scopes, domain allow-list, and auto-provisioning, which is off by default and, when switched on, provisions at Viewer and refuses any role that can manage users. PKCE is used; JWKS verification is asymmetric only. Environment-configured on purpose. There is no form an attacker can reach.
SAML_*IdP metadata, entity id, ACS URL and the signing certificate. Assertions are replayed-checked; HMAC signature methods are refused.
SSO_STEP_UPoff · if_enrolled · required: whether an SSO sign-in must also present a local second factor. SSO_WORKSPACE sets which workspace an auto-provisioned account joins.
WEBAUTHN_RP_ID
WEBAUTHN_ORIGINS
Must be a real domain: browsers refuse an IP address as a relying-party id. Pin both when running behind a proxy that rewrites Host.
Assurance and alerting
SIGNING_KEY_FILE
SIGNING_KEY
Where the Ed25519 package-signing key lives. In compose: /app/secrets/package_signing_key. Rotate with manage.py rotate_signing_key; the fingerprint is published at /api/signing-keys/.
CLAMAV_*Point at a clamd instance to scan uploads. In compose it is opt-in: docker compose --profile scanning up -d plus CONFORMITI_SCANNING=true. The hourly job is a liveness probe that alerts when clamd stops and when it returns; the re-scan sweep that quarantines stored files is manage.py scan_evidence, which you schedule yourself.
SLACK_WEBHOOK_URL
TEAMS_WEBHOOK_URL
Incoming webhooks, https only, set by an operator and nowhere else. Slack receives Block Kit, Teams an Adaptive Card.
MAX_UPLOAD_MB
PASSWORD_MIN_LENGTH
THROTTLE_LOGIN
Upload cap (32 MB default: raise client_max_body_size in the nginx config to match, or the proxy still returns 413), password policy, and the per-client login throttle. Counters live in Redis in the compose stack, so the limit is shared across workers; set NUM_PROXIES to your proxy hop count if you front it differently.
SEED_DEMO_DATADefaults to false. Set true on the first boot for the worked example, and only on something you do not intend to keep.

Full reference with defaults and examples: .env.example in the repository.

Operations

Running it, week to week.

Scheduled work

In Docker, Celery beat runs all of these except scan_evidence. The re-scan sweep is not on the schedule, so put it on cron yourself (the shipped guidance is monthly: 15 3 1 * * … scan_evidence --stale 30). Without Docker, put all of them on cron. The scans run once per workspace; token pruning is installation-wide.

management commands
$ manage.py send_review_reminders [--dry-run]
$ manage.py record_readiness
$ manage.py scan_evidence
$ manage.py flushexpiredtokens
$ manage.py send_digests

--dry-run on the reminder scan prints what it would send without sending it: worth running once after any mail configuration change.

Administration

Idempotent. seed_frameworks is how you pick up new or revised control libraries after an upgrade.

management commands
$ manage.py createsuperuser
$ manage.py seed_frameworks --with-folders
$ manage.py seed_frameworks --roles-only
$ manage.py remove_demo_data [--delete] [--workspace slug]
$ manage.py rotate_signing_key
$ manage.py link_oidc_identity
$ manage.py test_mailbox --to you@example.com

Backup and restore

Three things, and all three matter.

  • The database: pg_dump on a schedule you have actually tested restoring from
  • The media volume: the evidence files themselves; a database without them is a manifest of things you no longer have
  • The secrets volume: the Django secret key and the package signing key
example
$ docker compose exec -T db pg_dump -U compliance compliance | gzip > db.sql.gz
$ docker run --rm -v conformiti_media:/m -v "$PWD:/out" alpine \
      tar czf /out/media.tgz -C /m.

Upgrading

0.9.5 is the last version number: every release after it is a revision letter on it, and each one is maintenance. The upgrade note for each is in CHANGELOG.md, with its migration count and what to budget.

upgrade
$ git fetch --tags && git checkout v0.9.5j
$ docker compose pull && docker compose up -d --build
$ docker compose exec backend python manage.py migrate
$ docker compose exec backend python manage.py seed_frameworks --with-folders

Back up first. Across the whole 0.9.5 series only two revisions add a migration: 0.9.5b (accounts 0011, which encrypts stored webhook URLs) and 0.9.5f (accounts 0012). Every other letter is code and content only. Further back, 0.9.3 and 0.9.4 each add one (attestations 0006 and accounts 0009). Coming from 0.8.x, the 0.9.0 workspace change is ten migrations, one per app; each adds a column, moves every row into the Default workspace and makes the column required, inside one transaction on PostgreSQL. Budget a few seconds per hundred thousand rows.

Hardening

The list we would run through with you.

None of this is exotic. It is the set of things that, in our experience, separates an install that survives an assessor's questions from one that does not.

TLS at the edge, and /admin/ behind it

A proxy with a real certificate in front of nginx, BEHIND_TLS=true, HSTS at the edge. Cookie transport with __Host- prefixes is the default, and the prefix only works over https. Put /admin/ behind an allow-list or a VPN: the Django admin is an operator tool that sits outside the app's MFA, its login throttle and its workspace rules, and its session authenticates the whole API.

Second factor for everyone

TOTP or passkeys, enrolled by everyone with a management capability. There is no setting that enforces it, so it is a policy you run rather than a switch you flip. Backup codes belong to the account, so a passkey-only person is not locked out by a lost key. The Django admin does not ask for a second factor, which is why /admin/ belongs behind your proxy.

SSO with step-up

If your identity provider does not assert MFA, set SSO_STEP_UP=required so the app asks for a local factor. SSO that silently weakens your authentication is worse than no SSO.

Turn on malware scanning

It is off by default. Start the daemon and tell the API to use it: docker compose --profile scanning up -d with CONFORMITI_SCANNING=true. Then watch the health probe. The outage alert exists because a scanner that has quietly stopped is worse than one you never had, and put scan_evidence --stale 30 on a monthly cron so stored evidence is re-checked against new definitions.

Keep the signing key off the database host

A file, on a volume, backed up separately, ideally on a host that is not the one running PostgreSQL. Publish the fingerprint where your auditor can compare it against a bundle.

Publish a digest out of band

When you seal a package, note its manifest digest somewhere your assessor can see independently: an email, a ticket, a signed message. A signature proves a key signed it; the out-of-band digest is what binds it to a moment you both witnessed.

Review the audit log for auth events

login_failed carries a reason. A rise in one particular reason is a signal; the throttle is per client and shared across workers, so it is a real one.

Retire demo data, then verify

Only if you asked for it: it is off by default. Run remove_demo_data, then check Settings › About, which states plainly whether demo data is present. Do not take the command's word for it.

Restore from backup once, deliberately

Into a scratch environment, before you need to. An untested backup is a compliance finding in most frameworks and a genuine disaster in all of them.

Troubleshooting

The six things
that usually go wrong.

If none of these is it, open an issue with your /api/health/ output and the backend logs.

Open an issue
The site loads but I cannot sign in

Almost always DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS or CORS_ALLOWED_ORIGINS not listing the hostname you are actually using, including the scheme and port. Check the backend log: the rejection names the header it did not like. If you are behind a proxy, confirm it forwards Host and X-Forwarded-Proto.

Downloads fail or the browser refuses the file

If you have customised the nginx config, check that nothing adds a Content-Disposition header in the /protected-media/ location. Django sets it upstream and nginx passes it through, adding one produces two headers, and every browser refuses the response. This was a real bug between 0.3.0 and 0.5.0; the fix is to let the API own the header.

Passkeys will not enrol or will not verify

WEBAUTHN_RP_ID must be a domain name: browsers refuse an IP address, including 127.0.0.1. Use localhost for local work and set WEBAUTHN_ORIGINS to match exactly, including the port. If a proxy rewrites Host, pin both values rather than letting them be derived.

Reminder emails are not arriving

Run manage.py send_review_reminders --dry-run to see what the scan believes is due, and manage.py test_mailbox --to you@example.com to test the transport separately. Remember that each lead window is sent once and recorded on the document. A second run will not re-send yesterday's mail, which is correct behaviour and often mistaken for a failure.

Everything is 403 after upgrading to 0.9.0

An account with no workspace cannot make API requests. A superuser created by createsuperuser lands in the first active workspace automatically; anyone else in that position is refused with 403 by design. Assign the account a workspace under Settings › Role & access, or re-run the migration if it did not complete.

A file is stuck in quarantine

The re-scan sweep quarantines a stored file when updated definitions match it. That is the intended behaviour and the file is not deleted. Check the scanner status row and the notification; there is no manual release: a file leaves quarantine when a later scan_evidence run comes back clean, because the definitions were withdrawn upstream, or when you upload a new version of the document. Either way the release is recorded in the audit trail.

Need a hand with the first install?

A support subscription gets you a private channel and a response commitment. A consulting engagement gets someone doing it with you.