Docker Compose
DEBUG off, a generated secret key, rate limits in Redis, and only nginx on the network.
$ docker compose up -d --build $ docker compose ps $ docker compose logs -f backend
Self-hosting
AGPL-3.0 licensed, with no licence server, no telemetry and no phone-home. The only call you did not configure is the interface's two Google webfonts, which you can self-host.
The full documents ship in the repository: INSTALL.md, PREREQUISITES.md and .env.example.
$ git clone --branch v0.9.5oe https://github.com/dboudreau00/Conformiti.git $ cd Conformiti $ docker compose up -d --build
No .env needed. The install starts empty, so create your first account, then open http://localhost:8080.
$ docker compose exec backend python manage.py createsuperuserSEED_DEMO_DATA=true. The demo accounts are superusers with no second factor, so retire them before real data goes in (first-run checklist).
Docker Engine 24+ with Compose 2.24.0+, ports 8080 and 8000 free. 2 vCPU / 4 GB / 20 GB covers tens of users. Prebuilt amd64 and arm64 images are on ghcr.io/dboudreau00.
Python 3.11-3.14 and Node 20.19+ or 22.12+. SQLite and console email, for a trial.
PostgreSQL 16, Redis 7, a TLS proxy, a mail sender and a backup target.
S3 for evidence, ClamAV, OIDC or SAML, Slack or Teams, Jira Cloud.
Three ways in
DEBUG off, a generated secret key, rate limits in Redis, and only nginx on the network.
$ docker compose up -d --build $ docker compose ps $ docker compose logs -f backend
The same, but it waits for a healthy stack and prints the URLs.
$./install.sh --docker # or, without Docker: $./install.sh # validator, backend suite, frontend build: $./install.sh --test
$.\install.ps1 -Docker $.\install.ps1 -Test
SQLite and console email. Good for a look around, not for production.
$./install.sh # backend → 127.0.0.1:8000 # frontend → 127.0.0.1:5173 # mail printed to stdout
What comes up
Only nginx is published. It carries the CSP, the security headers and the 32 MB body cap.
/api/health/Topology
browser │:8080 ▼ nginx ─┬─▶ gunicorn (backend, :8000) ─┬─▶ PostgreSQL │ ▲ /api/health/ └─▶ Redis ├─ /static, /media (volumes) └─ CSP · security headers · 32 MB cap beat ──▶ Redis ──▶ worker ──▶ PostgreSQL · email volumes: pgdata · media · static · secrets · tree secrets holds DJANGO_SECRET_KEY_FILE and the package signing key: back both of them up.
Content-Disposition in an X-Accel location. The API sets it, and two copies make browsers refuse the file.Do this before real data
On Docker, run each command with docker compose exec backend python. Locally, use ../.venv/bin/python from backend/.
The password policy applies: 12 characters by default.
$ docker compose exec backend python manage.py createsuperuserOnly if you booted with SEED_DEMO_DATA=true. The five personas share one password, printed once in the backend log.
$ 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
DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS and PUBLIC_URL.
Then set BEHIND_TLS=true for secure cookies and the HTTPS redirect, and add HSTS at the proxy.
It holds the secret key and the package signing key. Lose the signing key and old packages still verify, but you cannot sign as the same identity again.
Configuration
Compose ships safe defaults and .env overrides them. These are the keys that matter most.
| Setting | Purpose |
|---|---|
| Core | |
DJANGO_DEBUG | false in Docker. The app refuses to boot in an obviously unsafe combination. |
DJANGO_SECRET_KEYDJANGO_SECRET_KEY_FILE | A key, or a path where one is generated and kept. Compose uses the file. |
DJANGO_ALLOWED_HOSTSCSRF_TRUSTED_ORIGINSCORS_ALLOWED_ORIGINS | Your public hostnames. The most common cause of an install that refuses logins. |
BEHIND_TLS | true behind a TLS proxy: secure cookies, HTTPS redirect, __Host- prefixes. |
PUBLIC_URL | The base for vendor-questionnaire links. |
ORGANISATION_NAME | Your name in outbound email and on the vendor's questionnaire page. |
| Data and mail | |
DATABASE_URL | PostgreSQL 16 in Docker and production, SQLite locally. |
EMAIL_PROVIDER | console · smtp · mailbox (IMAP/POP3 + SMTP) · ses. Test with manage.py test_mailbox --to you@example.com. |
REVIEW_SCAN_HOURREVIEW_ALERT_LEAD_DAYS | When the reminder scan runs, and its lead days (30, 14, 7, 1). |
S3_* | Optional S3 storage for evidence. |
| Identity | |
OIDC_* | Issuer, client, scopes and a domain allow-list. Auto-provisioning is off by default and never grants a role that manages users. |
SAML_* | IdP metadata, entity id, ACS URL and signing certificate. Replays and HMAC signatures are refused. |
SSO_STEP_UP | off · if_enrolled · required: whether SSO also needs a local second factor. |
WEBAUTHN_RP_IDWEBAUTHN_ORIGINS | A real domain, never an IP. Pin both behind a proxy that rewrites Host. |
| Assurance and alerting | |
SIGNING_KEY_FILESIGNING_KEY | The Ed25519 package-signing key. Rotate with manage.py rotate_signing_key. |
CLAMAV_* | Upload scanning, opt-in: docker compose --profile scanning up -d with CONFORMITI_SCANNING=true. |
SLACK_WEBHOOK_URLTEAMS_WEBHOOK_URL | Incoming webhooks, https only, set by an operator. |
MAX_UPLOAD_MBPASSWORD_MIN_LENGTHTHROTTLE_LOGIN | Upload cap (raise nginx's client_max_body_size to match), password policy and login throttle. |
SEED_DEMO_DATA | false by default. Only on something you do not intend to keep. |
Every key, with defaults: .env.example.
Operations
Celery beat runs these in Docker, except scan_evidence: put that on a monthly cron. Without Docker, cron all of them.
$ manage.py send_review_reminders [--dry-run] $ manage.py record_readiness $ manage.py scan_evidence $ manage.py flushexpiredtokens $ manage.py send_digests
All idempotent. seed_frameworks picks up revised libraries after an upgrade.
$ 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
The database, the media volume (the evidence itself) and the secrets volume. All three.
$ 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.
Back up, check out the new tag, rebuild. Migrations run on start.
$ git fetch --tags && git checkout v0.9.5oe $ docker compose pull && docker compose up -d --build
Each release's migrations and timing are in CHANGELOG.md. Coming from 0.8.x, budget a few seconds per hundred thousand rows.
Hardening
/admin/ behind a VPNThe Django admin sits outside the app's MFA, throttle and workspace rules, and its session reaches the whole API. Keep it behind an allow-list.
Nothing enforces it, so make it policy. Backup codes stop a lost key from locking anyone out.
If your provider does not assert MFA, set SSO_STEP_UP=required.
Off by default. Enable it, watch the health probe, and re-scan stored evidence monthly.
On its own volume, backed up separately, ideally off the database host.
Email the manifest digest to your assessor when you seal. It binds the package to a moment you both saw.
login_failed records a reason. A spike in one reason is a signal.
Settings › About says whether demo data is present. Check it.
Into a scratch environment, before you need to.
Troubleshooting
Not one of these? Open an issue with your /api/health/ output and the backend logs.
Your hostname is missing from DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS or CORS_ALLOWED_ORIGINS, scheme and port included. The backend log names the header it refused. Behind a proxy, forward Host and X-Forwarded-Proto.
Something in your nginx config adds Content-Disposition under /protected-media/. Remove it and let the API own the header.
WEBAUTHN_RP_ID must be a domain, never an IP. Use localhost locally and match WEBAUTHN_ORIGINS exactly, port included.
Run send_review_reminders --dry-run to see what is due, and test_mailbox to test the transport. Each reminder is sent once, so a second run sending nothing is correct.
The account has no workspace. Assign one under Settings › Role & access, or re-run the migration if it stopped part-way.
New scanner definitions matched it. Nothing is deleted, and there is no manual release: a clean re-scan or a new version frees it, and either is recorded.
Pro and Enterprise include support. Consulting puts someone beside you.