# Conformiti full stack: PostgreSQL · Redis · API · Celery worker/beat · nginx. # # docker compose up -d --build # then open http://localhost:8080 # # No .env is required: the defaults below suit a trial on a network you trust # (DEBUG off, a strong secret key generated on first boot and persisted in the # `secrets` volume, Redis-backed rate limits, API/admin reachable from the # network only through nginx; the API publishes no host port, see the note on # the backend service). They are not production settings: the stack serves # plain HTTP, prints email to its log, and uses the database password # `compliance` and a Redis with no password until .env says otherwise # (INSTALL.md, Going to production). # Most keys in .env override these defaults (see .env.example), but DEBUG, the # secret key, the field-encryption key and malware scanning are set here with # CONFORMITI_* names instead of their DJANGO_ and CLAMAV_ ones, on purpose (see # the note on DJANGO_DEBUG below), and DJANGO_ALLOWED_HOSTS adds your host # names to the stack's own instead of replacing them (see the note beside it). # The demo dataset is NOT seeded: set SEED_DEMO_DATA=true for the guided tour. # Its sign-in password is generated on first boot and printed once (docker # compose logs backend), and an installation carrying it says so on its # sign-in page until `manage.py remove_demo_data` is run. x-backend-env: &backend-env # DEBUG and the signing key are deliberately NOT read from DJANGO_DEBUG / # DJANGO_SECRET_KEY. Compose loads ./.env both for ${...} substitution *and* # into the containers, and ./.env is what the LOCAL installer writes, with # DJANGO_DEBUG=true and a development signing key. Without this indirection a # developer who ran ./install.sh and then `docker compose up` would silently # get a DEBUG container issuing tokens signed with their dev key. Override the # Docker stack on purpose with CONFORMITI_DEBUG / CONFORMITI_SECRET_KEY; # leaving the key empty makes the container generate and persist its own in # the `secrets` volume (DJANGO_SECRET_KEY_FILE below). DJANGO_DEBUG: ${CONFORMITI_DEBUG:-false} DJANGO_SECRET_KEY: ${CONFORMITI_SECRET_KEY:-} DJANGO_SECRET_KEY_FILE: /app/secrets/django_secret_key # Same indirection for the field-encryption ring that protects the TOTP # secret, the Jira token and the Slack and Teams webhook URLs at rest. Leave # CONFORMITI_FIELD_ENCRYPTION_KEY # unset (recommended) and the container generates one on first boot and # persists it in the `secrets` volume. Back that volume up: losing the # ring makes enrolled authenticators unreadable (backup codes still work). DJANGO_FIELD_ENCRYPTION_KEY: ${CONFORMITI_FIELD_ENCRYPTION_KEY:-} DJANGO_FIELD_ENCRYPTION_KEY_FILE: /app/secrets/django_field_encryption_key # The Ed25519 key that signs sealed package manifests. Generated on first # use into the `secrets` volume; never in the database. Back it up with # the volume and rotate with `manage.py rotate_signing_key`. SIGNING_KEY_FILE: /app/secrets/package_signing_key # Malware scanning. CONFORMITI_SCANNING rather than CLAMAV_ENABLED for the # same reason as DEBUG above: ./.env is shared with the LOCAL installer, and # CLAMAV_ENABLED=true there would make every local upload 503 against a # `clamav` hostname that does not resolve. Start the daemon with # docker compose --profile scanning up -d CLAMAV_ENABLED: ${CONFORMITI_SCANNING:-false} CLAMAV_HOST: clamav CLAMAV_PORT: "3310" # Your public host name(s) from .env, then the stack's own names, always. # The backend's healthcheck calls 127.0.0.1:8000, so a .env value that # replaced the default (DJANGO_ALLOWED_HOSTS=grc.example.com, as a # production install writes) leaves the backend unhealthy, and the worker, # beat and nginx never start. When .env sets nothing, the value begins # with an empty entry, which settings.py drops, so the list is the # stack's own names. A name listed twice is harmless. DJANGO_ALLOWED_HOSTS: "${DJANGO_ALLOWED_HOSTS:-},localhost,127.0.0.1,backend" CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080} # Plain HTTP on the LAN by default. Set BEHIND_TLS=true once a TLS-terminating # proxy sits in front of nginx (secure cookies, HTTPS redirect). BEHIND_TLS: ${BEHIND_TLS:-false} # These must be set here, not left to .env: settings.py only selects # PostgreSQL when POSTGRES_DB is non-empty, so relying on the (commented # out) .env lines silently drops the whole stack back to a container-local # SQLite file that is destroyed on every rebuild. POSTGRES_DB: ${POSTGRES_DB:-compliance} POSTGRES_USER: ${POSTGRES_USER:-compliance} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-compliance} POSTGRES_HOST: db POSTGRES_PORT: 5432 # Redis carries the Celery queue, the result store and the rate-limit # counters. REDIS_PASSWORD (letters and digits: it is spliced into these # URLs) puts AUTH in front of all three. Empty means no password, which is # tolerable only because the redis service publishes no port. CACHE_URL: redis://:${REDIS_PASSWORD:-}@redis:6379/2 CELERY_BROKER_URL: redis://:${REDIS_PASSWORD:-}@redis:6379/0 CELERY_RESULT_BACKEND: redis://:${REDIS_PASSWORD:-}@redis:6379/1 COMPLIANCE_TREE_ROOT: /data/compliance-data SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false} EMAIL_PROVIDER: ${EMAIL_PROVIDER:-console} # worker and beat load ./.env whole (env_file below), because the settings they # read from it are open-ended and compose has no way to pass "this key, only # when it is set". What they do not need is blanked here instead: an # `environment:` entry wins over the same key in the file. Secrets only the web # process uses stay on the backend service alone: # worker: the first administrator's account details, the demo password and the # single sign-on client secret; # beat: those too, and the mail and S3 credentials, because it only queues # tasks and sends nothing. # A new web-only secret belongs in this block (tools/validate.py checks the keys listed here). x-worker-env: &worker-env <<: *backend-env DJANGO_SUPERUSER_USERNAME: "" DJANGO_SUPERUSER_PASSWORD: "" DJANGO_SUPERUSER_EMAIL: "" DEMO_PASSWORD: "" OIDC_CLIENT_SECRET: "" x-beat-env: &beat-env <<: *worker-env EMAIL_HOST_PASSWORD: "" MAILBOX_PASSWORD: "" MAILBOX_SMTP_PASSWORD: "" AWS_ACCESS_KEY_ID: "" AWS_SECRET_ACCESS_KEY: "" AWS_SESSION_TOKEN: "" services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-compliance} POSTGRES_USER: ${POSTGRES_USER:-compliance} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-compliance} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-compliance} -d ${POSTGRES_DB:-compliance}"] interval: 5s timeout: 3s retries: 20 redis: image: redis:7-alpine restart: unless-stopped # volatile-lru, not allkeys-lru: the Celery queues and the beat schedule # carry no expiry and must never be evicted. Under allkeys-lru a busy hour # of throttle counters could silently drop queued reminder mail. Only keys # with a TTL (cache entries, rate-limit counters) are candidates. # `--requirepass ""` is Redis for "no password" when REDIS_PASSWORD is unset. command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "volatile-lru", "--requirepass", "${REDIS_PASSWORD:-}"] environment: REDIS_PASSWORD: ${REDIS_PASSWORD:-} # Nothing is persisted (no snapshots, no append-only file), but the image # declares /data a volume, so without this every new redis container gets # an anonymous volume that `docker compose down` leaves behind, one more on # each stop and start. tmpfs: - /data healthcheck: # With a password the first form answers; without one it is refused and # the plain ping answers instead. test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping 2>/dev/null | grep -q PONG || redis-cli ping | grep -q PONG"] interval: 5s timeout: 3s retries: 20 backend: build: ./backend restart: unless-stopped env_file: - path: .env required: false environment: <<: *backend-env # The first administrator, created on boot when a username and a # password are both set, and left alone once it exists. Listed here # rather than only read from ./.env so they also work set in the shell, # the way SEED_DEMO_DATA does. Only this service runs the entrypoint # that uses them, so the worker and beat blank them (x-worker-env). DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME:-} DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD:-} DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-} volumes: - media:/app/media - static:/app/staticfiles - secrets:/app/secrets - tree:/data/compliance-data depends_on: db: condition: service_healthy redis: condition: service_healthy # No host port: the API and admin are reached through nginx on # CONFORMITI_PORT, which appends the real client address to # X-Forwarded-For. A published gunicorn port skips nginx, so any client # on it chooses its own throttle key (NUM_PROXIES=1 trusts that header's # last entry), and the 8 a minute sign-in limit stops meaning anything. # To reach the API from the host while debugging, publish it from a # docker-compose.override.yml of your own (INSTALL.md, Debugging the API). worker: build: ./backend restart: unless-stopped # Run Celery directly (bypass the seeding entrypoint the backend uses) so # migrations/seeding happen once, in the backend container only. Waiting # for the backend to be *healthy* guarantees the tables exist first. # No -B: the scheduler is the `beat` service below, so this one can be # scaled (docker compose up -d --scale worker=3) without every reminder # going out three times. entrypoint: ["celery", "-A", "config", "worker", "-l", "info", "--concurrency", "2"] env_file: - path: .env required: false environment: <<: *worker-env volumes: - media:/app/media - secrets:/app/secrets:ro depends_on: backend: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD-SHELL", "celery -A config inspect ping -d celery@$$HOSTNAME --timeout 10 >/dev/null 2>&1 || exit 1"] interval: 60s timeout: 15s start_period: 60s retries: 3 # The scheduler: one of these, ever. It queues the daily review scan, the # readiness snapshot, the digests and the scanner watch for the workers. beat: build: ./backend restart: unless-stopped entrypoint: ["celery", "-A", "config", "beat", "-l", "info", "-s", "/tmp/celerybeat-schedule", "--pidfile", "/tmp/celerybeat.pid"] env_file: - path: .env required: false environment: <<: *beat-env volumes: - secrets:/app/secrets:ro depends_on: backend: condition: service_healthy redis: condition: service_healthy healthcheck: # Alive, and able to reach the broker it publishes to: a beat that lost # Redis stays up, queues nothing, and `kill -0` alone would call it healthy. # Opening the connection pings Redis (kombu does it when it creates the channel). test: ["CMD-SHELL", "kill -0 $$(cat /tmp/celerybeat.pid) 2>/dev/null && python -c \"import os; from kombu import Connection; Connection(os.environ['CELERY_BROKER_URL']).ensure_connection(max_retries=1, interval_start=0, timeout=5)\" 2>/dev/null || exit 1"] interval: 60s timeout: 15s start_period: 30s retries: 3 frontend: build: ./frontend restart: unless-stopped depends_on: backend: condition: service_healthy # nginx serves uploaded evidence and Django's collected static files # directly from the shared volumes (see frontend/nginx.conf). volumes: - media:/usr/share/nginx/media:ro - static:/usr/share/nginx/static:ro ports: - "${CONFORMITI_PORT:-8080}:80" # Malware scanning for uploaded evidence. Opt in with: # docker compose --profile scanning up -d # and set CONFORMITI_SCANNING=true so the API calls it. The first # start downloads the signature database, which takes a few minutes; freshclam # keeps it current afterwards. # # Off by default, deliberately. Scanning is fail-closed when it is on, which # is the right behaviour and the reason it is not the default: a first # `docker compose up` would pull this image and a virus-definition database # before anything worked, and refuse every upload with a 503 until freshclam # finished. Uploads that browsers execute, and macro-bearing Office files, # are refused outright by documents/uploads.py whether or not this runs. # Turn it on for any installation holding evidence you did not create. clamav: image: clamav/clamav:1.4 profiles: ["scanning"] restart: unless-stopped volumes: - clamdb:/var/lib/clamav - ./docker/clamd.conf:/etc/clamav/clamd.conf:ro healthcheck: test: ["CMD-SHELL", "clamdcheck.sh || exit 1"] interval: 60s timeout: 10s retries: 5 start_period: 300s volumes: clamdb: pgdata: media: static: secrets: tree: