Quick Start¶
Get a local or test instance of Suspicious running in minutes.
Installation & Setup¶
Run Suspicious with Docker, from a clean clone to a working login. Every command is copy-paste ready.
- Prerequisites
- Quick start (local) — a running app in ~10 minutes
- Configuration — the three config files
- Full deployment — all services + HTTPS + UI
- Operations — day-to-day commands
- Troubleshooting
- Uninstall
Prerequisites¶
| Tool | Minimum | Used for |
|---|---|---|
| Docker Engine | 24+ | runs every service |
| Docker Compose v2 | docker compose |
orchestration |
| Git | any | clone |
| curl | any | health checks, setup scripts |
JDK (keytool) |
any | TLS keystore — only for the Cortex/full stack |
| Python | 3.10+ | the optional install wizard |
Check your machine:
docker version
docker compose version
git --version
Building images pulls from PyPI, npm, and GHCR. Behind a proxy, set HTTP_PROXY
and HTTPS_PROXY in deployment/.env before building.
Quick start (local)¶
A single-machine setup for development and evaluation. It runs the database, cache, and Django backend, and signs you in to the admin. It skips the analyzer engine (Cortex), search (Elasticsearch), and the HTTPS proxy — add those in Full deployment.
The example configs ship with throwaway local credentials that work as-is. Change every secret before exposing this to anyone (see Configuration).
# 1. Clone
git clone https://github.com/thalesgroup-cert/suspicious.git
cd suspicious
# 2. Drop in the example configs (ready to run locally)
cp docs/getting-started/examples/deployment-env.example deployment/.env
cp docs/getting-started/examples/Suspicious-settings.example.json Suspicious/settings.json
cp docs/getting-started/examples/email-feeder-config.example.json email-feeder/config.json
cp docs/getting-started/examples/suspicious-ui-env.example suspicious-ui/.env
# 3. Create the Docker network and build the backend image
cd deployment
bash scripts/gen-service-secrets.sh .env .env.data # fills the data-service passwords
docker network create suspicious_net
docker compose build suspicious
# 4. Start the data stores, then the backend
docker compose up -d db_suspicious redis_cache redis_broker chromadb chromadb_proxy
docker compose up -d --no-deps suspicious
# 5. Initialise the database and create your admin account
make migrate
make seed-config
make createsuperuser
Open http://localhost:9020/admin/ and sign in.
Check the API is healthy:
curl --noproxy '*' http://localhost:9020/api/health/
# {"status": "ok", "checks": {"db": true, "redis": true, "cortex": false}, "failed": []}
cortex: false is expected here — the analyzer engine is part of the full stack.
The AI vote is not part of this quick start: the ai_model connector is off
by default and ai_inference is not started. Without it, mail verdicts that
need the AI vote end Inconclusive (rule analysis-incomplete). To turn it
on, see AI model.
Behind a corporate proxy, localhost calls may be intercepted. Add
--noproxy '*'tocurl, orexport NO_PROXY=127.0.0.1,localhost.
Local dev mail + LDAP (optional)¶
Two services in deployment/docker-compose.dev-extras.yml — opt-in only,
pass -f docker-compose.yml -f docker-compose.dev-extras.yml explicitly — stand
in for infrastructure most contributors don't have handy:
greenmail— a disposable SMTP+IMAP server (the same imageemail-feeder/docker-compose.e2e.yamluses for its own e2e test). PointSuspicious/settings.json→email.smtpandemail-feeder/config.json→mail/mail-connectors.imapat it (server: greenmail, port3025SMTP /3143IMAP) to submit and receive real mail locally without a corporate relay. SMTP AUTH is required — greenmail rejects an emptyemail.smtp.password; use the same credentials as the IMAP user(s) you define inGREENMAIL_IMAP_USER/GREENMAIL_IMAP_PASSWORD. IMAP requires a named account per mailbox you want to read (-Dgreenmail.users=user:pw@domain,...in the service'sGREENMAIL_OPTS) — an unlisted recipient still accepts SMTP delivery but has no IMAP account to read it back from.openldap(osixia/openldap) — a disposable LDAP directory for exercisingauthentication.ldapandprofiles/profiles_utils/ldap.py. Seed it withldapaddafter the container is healthy, not through the image's own bootstrap-ldif volume — that entrypoint chown/sed/rm's paths under/container/service/slapd/assets/config/bootstrap/ldif/in place, which fails with "Device or resource busy" against any bind-mounted file or directory at that exact path:See CONFIG.md § 2.8 for a caveat on testingdocker cp docker/openldap/custom/50-meridian.ldif openldap:/tmp/seed.ldif docker compose -f docker-compose.yml -f docker-compose.dev-extras.yml \ exec openldap ldapadd -x -D "cn=admin,dc=meridian,dc=example" \ -w "$LDAP_ADMIN_PASSWORD" -f /tmp/seed.ldifprofiles/profiles_utils/ldap.py's search against a plain-schema directory like this one.
Both are dev-only and never start unless you pass -f docker-compose.dev-extras.yml
explicitly — nothing to omit for a production deploy.
Configuration¶
Three files hold all configuration. They are git-ignored — create them from the templates (the Quick start step 2 copies ready-made examples).
| File | Template | Holds |
|---|---|---|
deployment/.env |
deployment/.env.example |
image versions, ports, paths, DB and storage credentials |
Suspicious/settings.json |
Suspicious/settings-sample.json |
Django settings, integrations, branding, SMTP |
email-feeder/config.json |
email-feeder/config-sample.json |
IMAP/IMAPS mailboxes, polling (S3 and SMTP come from the backend when FEEDER_API_TOKEN is set) |
The React UI also reads suspicious-ui/.env (suspicious-ui/.env.example) for
branding shown at runtime.
Set your own secrets¶
Generate a Django secret key and put it in Suspicious/settings.json →
app.secret_key:
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
Then set, at minimum:
deployment/.env→MYSQL_PASSWORD,MYSQL_ROOT_PASSWORD,MINIO_ROOT_PASSWORDSuspicious/settings.json→database.password,branding.*,email.*
Two rules for local runs (no Vault)¶
Suspicious/settings.json→database.passwordmust equaldeployment/.env→MYSQL_PASSWORD. Without Vault, the backend reads the database password fromsettings.json.- Set
app.debugtotruefor plain HTTP onlocalhost. Withdebugfalse, Django redirects to HTTPS and you must go through Traefik. Keepdebugfalse in production.
For production, move secrets into HashiCorp Vault and set VAULT_ADDR. See
deployment/VAULT.md.
Ports¶
| Port | Service |
|---|---|
| 9020 | Django API (and admin) |
| 9021 | React UI (Nginx) |
| 9001 | Cortex |
| 80 / 443 | Traefik (full stack) |
Full deployment¶
Runs all 13 services: backend, UI, Celery worker, email feeder, MariaDB, Elasticsearch, RustFS, ChromaDB, two Valkey instances, Cortex, Vault, and Traefik (HTTPS).
1. Initialise¶
make init creates config files from templates, generates TLS certificates and
the Cortex keystore, downloads the Cortex analyzer catalogs, and prepares data
directories.
cd deployment
make init
make init also generates the Redis/Elasticsearch/ChromaDB secrets (.env, .env.data) before any data store starts. It needs keytool (a JDK) and internet access for the Cortex catalogs.
2. Three settings the full stack requires¶
| Setting | File | Why |
|---|---|---|
CORTEX_PATH set to an absolute path (e.g. /opt/suspicious/cortex) |
deployment/.env |
Cortex mounts analyzer jobs into sibling containers by host path; a relative path fails with mount path must be absolute |
app.allowed_hosts includes your DOMAIN_CORP |
Suspicious/settings.json |
Traefik forwards that host; otherwise Django returns DisallowedHost |
VAULT_ADDR left unset (dev) or pointed at a provisioned Vault (prod) |
deployment/.env |
unset falls back to settings.json; set requires Vault to be reachable |
3. Build, start, initialise¶
cd deployment
docker compose build suspicious suspicious_ui
bash scripts/gen-service-secrets.sh .env .env.data # fills the data-service passwords
docker compose up -d db_suspicious redis_cache redis_broker chromadb chromadb_proxy
docker compose run --rm --no-deps suspicious python manage.py migrate --no-input
docker compose run --rm --no-deps suspicious python manage.py seed_config
docker compose run --rm --no-deps \
-e DJANGO_SUPERUSER_USERNAME=admin \
-e DJANGO_SUPERUSER_PASSWORD='change-me' \
-e DJANGO_SUPERUSER_EMAIL=admin@example.com \
suspicious python manage.py createsuperuser --noinput
docker compose up -d elasticsearch
bash scripts/es-security-bootstrap.sh # creates the Elasticsearch service accounts
docker compose up -d
docker compose ps
Wait for the services to report healthy (docker compose ps).
4. Reach it over HTTPS¶
Traefik serves the UI on DOMAIN_CORP and routes /api to Django. Point the
domain at your host:
echo "127.0.0.1 suspicious.meridian.example" | sudo tee -a /etc/hosts
Open https://suspicious.meridian.example/ and accept the self-signed
certificate. The admin is at /admin/.
# UI through Traefik
curl -sk --noproxy '*' -H "Host: suspicious.meridian.example" \
-o /dev/null -w "%{http_code}\n" https://127.0.0.1/
# API through Traefik
curl -sk --noproxy '*' -H "Host: suspicious.meridian.example" \
https://127.0.0.1/api/health/
# {"status": "ok", "checks": {"db": true, "redis": true, "cortex": true}, ...}
Two services may report unhealthy in a bare setup, by design:
- email_feeder restarts until you enable a mailbox in
email-feeder/config.json. With every connector disabled it has nothing to poll and exits. - rustfs is only used when
storage.backendiss3. The quick start useslocalstorage.
Email ingestion¶
Forward suspicious emails (as attachments) to the mailbox configured in
email-feeder/config.json. The feeder imports them and queues analysis.
AI model (optional, off by default)¶
The AI vote comes from the ai_inference model server, called by the
ai_model connector. There is no Cortex AI analyzer any more. The connector is
disabled on a fresh or upgraded instance, and the token in deployment/.env
and the connector token must be the same value.
- Generate a token and set it in
deployment/.envasAI_INFERENCE_TOKEN(openssl rand -hex 24). Nothing generates it for you, and the exampledeployment-env.exampleleaves it empty. - Put the same value in
integrations.ai_model.token(Suspicious/settings.jsonor the Settings tile). A mismatch shows asrejected the token (HTTP 401). - Place the model weights in
Analyzers/AIMailAnalyzer/models/and start the server:make upenables the profile; with plain compose usedocker compose --env-file .env --profile ai up -d --build ai_inference. - Enable the connector (Settings, connector gallery, tile
ai_model).
Full procedure, weights, retraining and troubleshooting: AI inference.
Object storage provider¶
storage.backend: s3 uses RustFS or any S3-compatible store. Google Cloud
Storage is also available (storage.provider: gcs, credentials in Vault as
storage.gcs.service_account_json); see CONFIG.md § 2.6
for its limits.
Feeder config authority (optional)¶
When enabled, the feeder fetches its S3 and SMTP config from the
backend at boot instead of reading those blocks from email-feeder/config.json.
The backend must be reachable on first run; the feeder caches the last-good
response and tolerates a backend blip after that. With no cache and no backend
it exits. Leaving FEEDER_API_TOKEN blank keeps the feeder on its local
config.json.
# 1. Provision the feeder's scoped token (run once)
docker compose exec suspicious python manage.py create_service_token feeder
# 2. Paste the printed token into deployment/.env
# FEEDER_API_TOKEN=<token> (BACKEND_URL=http://suspicious:9020 is preset)
# 3. Restart the feeder so it fetches shared config from the backend
docker compose up -d --force-recreate feeder
Production secrets with Vault (optional)¶
The steps above read secrets from settings.json (the VAULT_ADDR-unset path).
For production, store them in HashiCorp Vault instead. Full reference and the
secret map: deployment/VAULT.md.
cd deployment
# 1. Enable Vault in deployment/.env
# VAULT_ADDR=http://vault:8200
# VAULT_PORT=8200
# VAULT_PATH=./vault
# 2. Start + initialise Vault (it boots sealed on every restart)
make up # stages the vault service and unseals it
docker compose --env-file .env exec vault vault operator init # FIRST BOOT ONLY — save unseal keys + root token
install -m 600 /dev/null vault/unseal.keys # paste one unseal key per line (>= threshold)
make unseal # idempotent; also auto-run by up/deploy
# 3. Provision the AppRole + policy, then seed the secrets
export VAULT_TOKEN=<root-token>
make provision-vault
export SECRET_KEY=... DB_PASSWORD=... CORTEX_API_KEY=... CORTEX_WEBHOOK_SECRET=... S3_SECRET_KEY=...
export WATCHER_API_KEY=... # optional connector secrets
make seed-vault-secrets
# 4. Apply
make deploy
- Once
vault/unseal.keysexists,make upandmake deployunseal Vault automatically — no manual step after a restart. AI_INFERENCE_TOKENis not a Vault secret: it is an.envvalue you create by hand andseed-vault-secretsdoes not touch it. The matching connector token (integrations.ai_model.token) is a connector secret, see below.- Connector secrets (cortex/thehive/misp/watcher/ai_model/...) can also just be set in
settings.jsonor edited in the Settings UI: on boot the app seeds any present-but-missing connector secret into Vault, and reads fall back Vault →settings.json. Boot-critical secrets (app.secret_key,database.password) stay Vault/ops-seeded by design.
Upgrading an existing instance¶
make deploy pulls, migrates and restarts. Two one-time steps apply to an
existing stack:
- Authenticated data services.
make deploystops untilmake db-auth-migratehas run (maintenance window of about 2 minutes), see Deploy & Rollback. - AI model. The Cortex AI analyzer is removed. The
ai_modelconnector is off by default, so an upgraded instance has no AI vote until you: setAI_INFERENCE_TOKENindeployment/.env, put the same value inintegrations.ai_model.token, startai_inference(--profile aiwith plain compose), enable the connector and set thechromadbcollection_name. Keepintegrations.cortex.analyzers.aiinsettings.json. See AI inference, section 10.
Operations¶
Run from deployment/.
| Command | Action |
|---|---|
make init |
Prepare configs, certificates, Cortex catalogs |
make up |
Create the network and start all services |
make down |
Stop all services (data is kept) |
make migrate |
Apply database migrations |
make seed-config |
Load runtime config into the database |
make createsuperuser |
Create an admin account |
make deploy |
Pull, migrate, seed, restart (production update) |
make unseal |
Unseal Vault from vault/unseal.keys (auto-run by up/deploy) |
make provision-vault |
Enable Vault KV + AppRole, write role/secret IDs to .env |
make seed-vault-secrets |
Write exported secret env vars into Vault |
make logs make logs s=suspicious |
Follow logs (all, or one service) |
make status |
Service health |
make shell |
Shell into the backend container |
make db-shell |
MariaDB shell |
make backup-db / make restore-db f=<file> |
Backup / restore |
Every target maps to a docker compose command, so you can run them directly if
you prefer.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Access denied for user 'suspicious' |
settings.json DB password ≠ .env MYSQL_PASSWORD, or a stale database volume |
Match the two; for stale data, docker volume rm suspicious_db_suspicious_data and restart the DB |
curl localhost:9020 returns 403 Server: Zscaler |
a corporate proxy intercepts localhost | curl --noproxy '*' or export NO_PROXY=127.0.0.1,localhost |
Backend redirects to HTTPS / unreachable over http:// |
app.debug is false |
set app.debug to true for local dev |
network suspicious_net not found |
external network not created | docker network create suspicious_net (or make up) |
could not read secret … from Vault at http://vault:8200 |
VAULT_ADDR points at an unreachable Vault |
leave VAULT_ADDR unset for local dev |
Cortex won't start: mount path must be absolute |
CORTEX_PATH is relative |
set CORTEX_PATH to an absolute path |
DisallowedHost over HTTPS |
app.allowed_hosts missing DOMAIN_CORP |
add your domain to allowed_hosts |
:9021/api/... returns the page HTML, not JSON |
running a suspicious_ui image built before the nginx /api proxy was added |
rebuild it (docker compose build suspicious_ui), or reach the API through the Traefik domain |
docker compose build → uv pip install timeout |
no proxy on a proxy-only network | set HTTP_PROXY / HTTPS_PROXY in .env |
manage.py seed_config: Unknown command |
running a published image older than your code | build from source (docker compose build suspicious) |
Uninstall¶
cd deployment
docker compose down # stop services, keep data
docker compose down -v # also delete database, storage, and search volumes
docker network rm suspicious_net