Prerequisites
Docker and Docker Compose are the only requirements — everything else (Django, Celery, Redis, the scan tools) ships inside the images.
1. Configure your environment
# Copy over the provided .env.example to your machine
cp .env.example .env
Edit .env and set at minimum:
| Variable | What it’s for |
|---|---|
DJANGO_SECRET_KEY |
generate one |
DJANGO_API_KEYS |
comma-separated list of accepted X-API-Key values |
REEF_AGENT_API_KEY |
the key the agent uses — must equal one of the values in DJANGO_API_KEYS |
DJANGO_DEBUG |
true for local dev |
DJANGO_SUPERUSER_* |
the login created automatically on first start |
You can generate a decently secure key with: openssl rand -hex 32. Below is the example ENV file provided to you out of the box. Not all of these are required. For more advanced setups with the optional Ollama service, checkout the Configuration page.
# Copy to .env and fill in real values: cp .env.example .env
# .env is gitignored and is read by both docker compose and the app container.
# --- Django ---------------------------------------------------------------
# Generate with:
# python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
DJANGO_SECRET_KEY=replace-me
# "true" enables debug mode. Leave false for anything shared/deployed.
DJANGO_DEBUG=false
# Required when DJANGO_DEBUG=false. Comma-separated, no spaces.
# The compose service name `web` is added automatically (the agent, worker and
# beat containers reach the web service by that name); override with
# REEF_INTERNAL_WEB_HOST if you rename the service.
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# Any host/IP you open the UI on. Reaching it by LAN IP or hostname without
# adding it here gives a bare "Bad Request (400)" page.
# e.g. DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.1.50,reef.example.com
#
# CSRF trust for POST actions (login, acknowledge, run scan, ...). Leave BLANK
# and every concrete host above is trusted automatically on http+https, bare and
# :8000. Set it explicitly (scheme required) only to override that:
# e.g. DJANGO_CSRF_TRUSTED_ORIGINS=https://reef.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=
# "true" when a TLS-terminating proxy / load balancer sits in front (it forwards
# plain http to the app). Makes Django trust X-Forwarded-Proto / -Host.
DJANGO_TRUST_PROXY=false
# --- Superuser -----------------------------------------------------------
# Created on first container start by entrypoint.sh (manage.py ensure_superuser).
DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_PASSWORD=replace-me
DJANGO_SUPERUSER_EMAIL=admin@example.com
# --- API keys ----------------------------------------------------------
# Accepted in the `X-API-Key` header by the token-protected API routes and by
# the agent. Comma-separated; supply several to allow rotation.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DJANGO_API_KEYS=
# --- Agent -------------------------------------------------------------
# The agent authenticates with this key; it MUST be one of DJANGO_API_KEYS.
REEF_AGENT_API_KEY=
# Optional; defaults to the monitored host's /etc/hostname (stable across restarts).
REEF_AGENT_NAME=
# Seconds between job polls / heartbeats.
REEF_POLL_INTERVAL=15
# --- Reef behaviour --------------------------------------------------
# Findings at or above this severity raise a notification (info|low|medium|high|critical).
REEF_NOTIFY_MIN_SEVERITY=high
# An agent with no check-in for this long is marked offline.
REEF_AGENT_OFFLINE_AFTER_SECONDS=300
# When "true", the auditd scan loads Reef's audit.rules into the host kernel
# (auditctl -R). Leave "false" to only *report* which rules are missing. To also
# persist the ruleset across reboots, add a read-write mount of /etc/audit to the
# agent service in docker-compose.yml.
REEF_AUDIT_MANAGE=false
# Size cap for the agent's scan scratch tmpfs (syft/grype temp files + the
# intermediate SBOM the grype scan builds). RAM-backed: size it to what the host
# can spare. A scan that needs more space fails cleanly with ENOSPC rather than
# filling the host disk. Grype's vuln DB lives on the grype-db volume, not here.
REEF_SCAN_TMP_SIZE=2g
# --- Celery ---------------------------------------------------------
# docker-compose sets these; override here if pointing at an external Redis.
# CELERY_BROKER_URL=redis://redis:6379/0
# CELERY_RESULT_BACKEND=redis://redis:6379/1
# --- SQLite3 ------------------------------------------------------
# Path inside the container (mounted volume so the DB survives rebuilds).
SQLITE_PATH=/data/db.sqlite3
# --- AI interpretation (optional, opt-in) -----------------------------
# Adds an "Interpret" button to the scan detail page and finding modals that
# sends scan/finding data to a local Ollama model and shows back a plain-
# language summary. Off unless OLLAMA_ENABLED=true. Needs real memory/CPU to
# run acceptably -- leave this off on a small box.
#
# 1. Uncomment the four lines below.
# 2. Start the bundled Ollama service too:
# COMPOSE_PROFILES=ollama docker compose up -d
# (or add `COMPOSE_PROFILES=ollama` as its own line in this file). The
# `ollama-init` one-shot service pulls OLLAMA_MODEL on first start.
# OLLAMA_ENABLED=true
# OLLAMA_URL=http://ollama:11434
# OLLAMA_MODEL=llama3.2:3b
# OLLAMA_MEM_LIMIT=8g
2. Start everything
You will need to login to the PortIgniter registry using the email used when you purchased the license, and the password is the license itself. Attempting to pull images without doing the docker login will result in an error.
The Base Compose File
Below is the base compose file to get up and running on single host. You will need to modify this file for any agent containers running on other hosts. The ollama service is optional. I recommend leaving it off unless you have a pretty decent server. You also have the option of using another OLLAMA based endpoint.
name: portigniter-reef
x-app: &app
# Check the product page for the latest release
image: registry.portigniter.com/reef:2026.09.15
env_file: .env
environment:
SQLITE_PATH: ${SQLITE_PATH:-/data/db.sqlite3}
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/1
volumes:
- "app-data:/data"
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
services:
web:
<<: *app
command: ["web"]
ports:
- "8000:8000"
healthcheck:
# green once gunicorn is actually serving -- lets the agent wait for a
# listening port instead of logging a startup "connection refused".
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health/', timeout=3).status == 200 else 1)"]
interval: 10s
timeout: 5s
retries: 6
start_period: 40s
worker:
<<: *app
command: ["worker"]
depends_on:
redis:
condition: service_healthy
web:
condition: service_started
beat:
<<: *app
command: ["beat"]
depends_on:
redis:
condition: service_healthy
web:
condition: service_started
redis:
image: redis:7.4.10-alpine
command: redis-server --appendonly yes # Enable AOF
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- redis-data:/data
agent:
image: registry.portigniter.com/reef-agent:2026.09.15
env_file: .env
environment:
REEF_API_URL: http://web:8000
REEF_SCAN_ROOT: /host
# Persist grype's vulnerability DB (grype-db volume) so it isn't
# re-downloaded and rebuilt (~1-2 GB) on every scan.
GRYPE_DB_CACHE_DIR: /var/cache/grype
# syft/grype scratch + our intermediate SBOM land here -- a size-capped
# tmpfs (see below), so a runaway catalogue can't fill the host disk.
TMPDIR: /scan-tmp
# REEF_AUDIT_MANAGE (from .env): when true the auditd scan loads Reef's
# ruleset into the host kernel. To also persist it across reboots, add a
# read-write mount: - "/etc/audit:/host/etc/audit:rw"
# Privileged + host PID + host root are what let lynis / clamav / FIM see the
# real system. The host filesystem is mounted read-only.
privileged: true
pid: "host"
volumes:
- "/:/host:ro"
- "/var/log/audit:/var/log/audit:ro"
- "clamav-db:/var/lib/clamav"
- "grype-db:/var/cache/grype"
- "agent-state:/var/lib/reef"
# Hard cap on scan scratch space. It's RAM-backed: size it to what the host
# can spare (REEF_SCAN_TMP_SIZE in .env). A scan that needs more fails
# cleanly with ENOSPC instead of eating the disk. Swap to a named volume
# here if you'd rather trade the disk risk back for not spending RAM.
tmpfs:
- "/scan-tmp:size=${REEF_SCAN_TMP_SIZE:-2g},mode=1777"
depends_on:
web:
condition: service_healthy
restart: unless-stopped
healthcheck:
# green once the poll loop has written its heartbeat in the last 2 min
test: ["CMD", "python3", "-c", "import os,time,sys; f='/tmp/reef-agent.heartbeat'; sys.exit(0 if os.path.exists(f) and time.time()-os.path.getmtime(f) < 180 else 1)"]
interval: 20s
timeout: 5s
retries: 3
start_period: 60s
# Local LLM for the opt-in "Interpret" feature (scan/finding AI summaries).
# Off by default -- only started with `docker compose --profile ollama up`
# (or COMPOSE_PROFILES=ollama in .env). Also set OLLAMA_ENABLED=true plus
# OLLAMA_URL/OLLAMA_MODEL in .env so the web/worker containers pick it up;
# see .env.example.
ollama:
image: ollama/ollama:latest
profiles: ["ollama"]
environment:
OLLAMA_HOST: 0.0.0.0
volumes:
- ollama-data:/root/.ollama
# Small model (~2GB) -- still give the container a memory ceiling so a
# customer on a modest box can't have it pushed into OOM-killing something
# else. Override via OLLAMA_MEM_LIMIT in .env.
mem_limit: ${OLLAMA_MEM_LIMIT:-8g}
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
restart: unless-stopped
# One-shot: pulls the model into the `ollama-data` volume, then exits.
# Re-running compose is a no-op once the model is already pulled.
ollama-init:
image: ollama/ollama:latest
profiles: ["ollama"]
environment:
OLLAMA_HOST: http://ollama:11434
entrypoint: ["ollama", "pull", "${OLLAMA_MODEL:-llama3.2:3b}"]
depends_on:
ollama:
condition: service_healthy
restart: "no"
volumes:
app-data: {}
redis-data: {}
clamav-db: {}
grype-db: {}
agent-state: {}
ollama-data: {}
The Agent only Compose File
Use the below for any stand-alone hosts you only want to install the agent on.
name: portigniter-reef
services:
agent:
image: registry.portigniter.com/reef-agent:2026.09.15
env_file: .env
environment:
REEF_API_URL: https://reef.yourdomain.com
REEF_SCAN_ROOT: /host
# Persist grype's vulnerability DB (grype-db volume) so it isn't
# re-downloaded and rebuilt (~1-2 GB) on every scan.
GRYPE_DB_CACHE_DIR: /var/cache/grype
# syft/grype scratch + our intermediate SBOM land here -- a size-capped
# tmpfs (see below), so a runaway catalogue can't fill the host disk.
TMPDIR: /scan-tmp
# REEF_AUDIT_MANAGE (from .env): when true the auditd scan loads Reef's
# ruleset into the host kernel. To also persist it across reboots, add a
# read-write mount: - "/etc/audit:/host/etc/audit:rw"
# Privileged + host PID + host root are what let lynis / clamav / FIM see the
# real system. The host filesystem is mounted read-only.
privileged: true
pid: "host"
volumes:
- "/:/host:ro"
- "/var/log/audit:/var/log/audit:ro"
- "clamav-db:/var/lib/clamav"
- "grype-db:/var/cache/grype"
- "agent-state:/var/lib/reef"
# Hard cap on scan scratch space. It's RAM-backed: size it to what the host
# can spare (REEF_SCAN_TMP_SIZE in .env). A scan that needs more fails
# cleanly with ENOSPC instead of eating the disk. Swap to a named volume
# here if you'd rather trade the disk risk back for not spending RAM.
tmpfs:
- "/scan-tmp:size=${REEF_SCAN_TMP_SIZE:-2g},mode=1777"
restart: unless-stopped
healthcheck:
# green once the poll loop has written its heartbeat in the last 2 min
test: ["CMD", "python3", "-c", "import os,time,sys; f='/tmp/reef-agent.heartbeat'; sys.exit(0 if os.path.exists(f) and time.time()-os.path.getmtime(f) < 180 else 1)"]
interval: 20s
timeout: 5s
retries: 3
start_period: 60s
volumes:
clamav-db: {}
grype-db: {}
agent-state: {}
The agent only .env looks as follows:
# --- Agent -------------------------------------------------------------
# The agent authenticates with this key; it MUST be one of DJANGO_API_KEYS.
REEF_AGENT_API_KEY=somelongkeyhere
# Optional; defaults to the monitored host's /etc/hostname.
REEF_AGENT_NAME=
# Seconds between job polls / heartbeats.
REEF_POLL_INTERVAL=15
# --- Reef behaviour --------------------------------------------------
REEF_NOTIFY_MIN_SEVERITY=high
REEF_AGENT_OFFLINE_AFTER_SECONDS=300
# "true" => auditd scan loads Reef's audit.rules into the host kernel.
REEF_AUDIT_MANAGE=false
# Size cap for the agent's scan scratch tmpfs (syft/grype temp + intermediate
# SBOM). RAM-backed; a scan needing more fails cleanly instead of filling disk.
REEF_SCAN_TMP_SIZE=2g
Ensure your docker-compose.yml file is in the same directory you put your .env file for both the all in one and any agent only installs.
# Starting the stack without Ollama
docker compose up --build
# Starting the stack with Ollama
docker compose up -d --profile ollama
On first start, the web service runs migrations, creates the superuser from
DJANGO_SUPERUSER_*, and seeds the default scan schedules (manage.py reef_seed
— nightly file integrity monitoring, hourly auditd, weekly Lynis, daily ClamAV/YARA/Grype, weekly
OpenSCAP). You can edit or add to these later at /admin/scanning/scanschedule/.
3. Log in and confirm the agent checked in
Open http://localhost:8000/ or the domain name, if using a forward-facing proxy, and you should be greeted with the below login page.

Here, you can log in as the superuser, as set in your ENV. On first login, you will land on the Reef Dashboard.

Click on Agents in the Navigation menu to see the host agent. Within 15 seconds or so, your configured agent(s) should start showing up as online — that’s the check-in loop working. This is the result of a background POST /api/agent/checkin/ happening every REEF_POLL_INTERVAL seconds. If you’re deploying for the first time, you will only see the agent deployed on the same box as the web interface is. In the example below, is a setup with three separate agents.

4. Run your first scan
Runing a quick Lynis scan is the easiest smoke test. The job detail page auto-refreshes until it flips to done; findings land on /findings/ and the job summary shows the package count and detected distro. A high/critical finding raises a notification — bell badge, /notifications/, and an email copy in the web container logs (console email backend in dev).
5. See File Integrity Monitoring detect a change
Next let’s do: Run scan → File Integrity twice:
- The first run establishes the baseline and reports no findings.
toucha file under/etcon the host, then run it again — you should see a finding for the changed file.
6. Let the schedules run
Leave the stack running for a minute or two and beat will enqueue the seeded schedules automatically — no action needed.
Next
- Architecture for how the services relate to each other
- Scans for what each of the seven scan types actually checks
- Management Commands for
reef_seed,reef_reset, and the othermanage.pycommands