Configuration Reference¶
An install is configured in two places: the environment variables of .env, beside docker-compose.yml in deploy/, for the domain, the secrets and the infrastructure; and spot.yaml, on the spot-config volume, for the plugins, the workflows, the routing and the platform settings, which the dashboard, the CLI and the API edit. This page lists the variables, then the spot.yaml settings an operator sets most.
Required Settings¶
These must be set before first start:
| Variable | Description | Example |
|---|---|---|
DOMAIN | Your domain name | spot.example.com |
ACME_EMAIL | Email for Let's Encrypt | admin@example.com |
SECRET_KEY | JWT signing key (32+ chars) | Generate with command below |
ENCRYPTION_KEY | Fernet key for stored emails, plugin secrets, plugin tokens and the plugin sources' tokens | Generate with command below |
POSTGRES_PASSWORD | Database password | Use strong password |
RABBITMQ_DEFAULT_PASS | Message queue password | Use strong password |
REDIS_PASSWORD | Redis password, letters and digits only: it goes into the service URLs | openssl rand -hex 32 |
Generate keys:
# SECRET_KEY
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# ENCRYPTION_KEY
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
URLs¶
After deployment, your services will be available at:
- Dashboard:
https://DOMAIN(e.g.,https://spot.example.com) - API:
https://api.DOMAIN(e.g.,https://api.spot.example.com) - Traefik Dashboard:
https://traefik.DOMAIN(optional, requiresTRAEFIK_DASHBOARD_AUTH)
Component Versions¶
| Variable | Description | Default |
|---|---|---|
CORE_VERSION | API Gateway and orchestrators version | See docker-compose.yml |
DASHBOARD_VERSION | Web dashboard version | See docker-compose.yml |
Database¶
| Variable | Description | Default |
|---|---|---|
POSTGRES_DB | Database name | spot |
POSTGRES_USER | Database user | spot |
POSTGRES_PASSWORD | Database password | None - Required |
Redis¶
| Variable | Description | Default |
|---|---|---|
REDIS_PASSWORD | Redis password. Letters and digits only: it goes into the services' redis:// URLs | None - Required |
REDIS_MAXMEMORY | Max memory for Redis's data. Redis's memory limit, 512M, must hold it and an AOF rewrite's fork: past about 400mb, raise that limit too, in docker-compose.override.yml (see Memory limits) | 256mb |
REDIS_MAXMEMORY_POLICY | Eviction policy | allkeys-lru |
RabbitMQ¶
| Variable | Description | Default |
|---|---|---|
RABBITMQ_DEFAULT_USER | RabbitMQ username | spot |
RABBITMQ_DEFAULT_PASS | RabbitMQ password | None - Required |
Analysis concurrency¶
| Variable | Description | Default |
|---|---|---|
MAX_CONCURRENT_JOBS | Analyses the analyzer-orchestrator runs at once from the bulk queue: user submissions, re-analyses and the ingests of retrievers that do not wait | 10 |
MAX_CONCURRENT_SYNC_JOBS | Analyses it runs at once from the queue of the retrievers that wait for their verdict, such as the milter | 4 |
A retriever that waits for its verdict has slots of its own, so a backlog of bulk analyses never holds it up. An ingest not started before the retriever stops waiting is analysed later with the bulk analyses (see Mail integration). The analyzers are shared: up to MAX_CONCURRENT_JOBS + MAX_CONCURRENT_SYNC_JOBS analyses, 14 by default, reach them at once. Lower MAX_CONCURRENT_JOBS if they cannot take that many.
Each running analysis holds its email's view in the analyzer-orchestrator. At the defaults the SDK's parser caps a view at about 6.8 MiB, so intake.max_view_mib (8) does not bind. The orchestrator's memory limit, 1536M, is sized for 14 analyses at once: 14 analyses of a 5.2 MiB view, with two analyzers in their stage, peaked at 581 MiB, and 1536M is twice that, rounded up. It holds for stages of up to two analyzers, the measured width and the default workflow's. Each further analyzer in a stage holds about two more copies of the view per analysis, so a workflow with a wider stage needs the limit raised, and so does a higher concurrency: raise it in docker-compose.override.yml (see Memory limits).
Traefik / SSL¶
| Variable | Description | Default |
|---|---|---|
DOMAIN | Base domain for services | None - Required |
ACME_EMAIL | Let's Encrypt email | None - Required |
TRAEFIK_LOG_LEVEL | Traefik log level | INFO |
TRAEFIK_DASHBOARD_AUTH | Basic auth for Traefik dashboard | Empty (disabled) |
Generate dashboard auth:
Security¶
| Variable | Description | Default |
|---|---|---|
SECRET_KEY | JWT signing key | None - Required |
ALLOWED_HOSTS | Additional allowed hostnames | Auto-includes api.DOMAIN |
SPOT_METRICS_TOKEN | The token a scraper sends to every core service's /metrics (openssl rand -hex 32); unset or the CHANGE_ME_ placeholder, /metrics answers 503. See Monitoring | None |
Email Storage¶
| Variable | Description | Default |
|---|---|---|
ENCRYPTION_KEY | Fernet key for stored emails, plugin secrets, plugin tokens and the plugin sources' tokens | None - Required |
Without ENCRYPTION_KEY, the api-gateway, the analyzer-orchestrator and the migration job refuse to start. The key also seals the plugin secrets and the plugin sources' tokens in spot.yaml and protects the plugin tokens: back it up with the database.
Retention¶
A received email is kept for platform.data_retention_days of spot.yaml, 365 days by default (1 to 36500), with its raw copy, its analyses and their signals. The api-gateway purges the expired emails when it starts and then daily. Change it as an admin with PUT /api/v1/config/platform:
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data_retention_days": 90}' https://api.$DOMAIN/api/v1/config/platform
A change applies to the emails stored afterwards: each email keeps the expiry it was stored with.
A copy of an email promoted into a dataset is exempt: the purge never deletes it, whatever its age, until it is removed from its dataset or the dataset is deleted. A copy holds the raw message, its view and its label; the email it was copied from keeps its own expiry, and deleting that email leaves its copies. To erase a person's mail, delete their emails and remove their copies: an email's page lists its copies, and a copy whose original is gone is listed on its dataset's page, with its sender. Each promotion and removal is in the audit log (dataset.promote, dataset.remove, and dataset.delete for a dataset deleted with its copies).
An entry of the audit log is kept for platform.audit_retention_days, 365 days by default (30 to 3650); the same daily purge deletes the older ones, and a change applies at the next purge:
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"audit_retention_days": 730}' https://api.$DOMAIN/api/v1/config/platform
Intake and api-gateway memory¶
The api-gateway parses each received message in worker processes, within the bounds of the intake section of spot.yaml (see Mail and Intake in the configuration reference). The compose file limits its memory to 3328M, what the defaults need:
512M + max_in_flight x 256M + max_content_in_flight x 256M
+ parse_workers x parse_memory_mib
If you raise these settings, raise the limit with them in docker-compose.override.yml. For example, parse_workers: 3 (so max_in_flight 6) needs 512 + 6 x 256 + 2 x 256 + 3 x 640 = 4480M:
The api-gateway reads intake when it starts: restart it after a change.
Analyzers¶
Critical: Analyzers Must Be Installed
SPOT is an orchestration platform ; it coordinates analyzer plugins but does not include them. Without analyzers:
- Every analysis ends with the verdict
unknown - The analyzer-orchestrator logs, for each analyzer a workflow names,
Analyzer <id> in stage <stage>: no URL configured - No phishing detection can occur
Install the analyzers your workflows name before SPOT can function. The default workflow, default-workflow, runs analyzer-nlp and analyzer-llm.
Analyzers (and other plugins) are installed and configured through the dashboard, not via environment variables. See Installing plugins for the full flow.
Official analyzers¶
| Plugin | Description | Repository |
|---|---|---|
analyzer-rules | Rule-based contextual analysis | analyzer-rules |
analyzer-nlp | NLP analysis (DistilBERT, NER) | analyzer-nlp |
analyzer-ml | Classical machine learning on the email's features | analyzer-ml |
analyzer-llm | LLM analysis (OpenAI-compatible server, such as Ollama) | analyzer-llm |
analyzer-misp | Indicator lookups in your MISP instance | analyzer-misp |
Where the configuration lives¶
Installed analyzers are tracked in spot.yaml, /app/config/spot.yaml in the api-gateway on the spot-config volume, under plugins.analyzers.<id>. The dashboard writes this file when you install, enable, disable, or change settings. The orchestrator re-reads it per job, so no restart is needed; saving a plugin's settings restarts that plugin.
Core calls each analyzer with the analyzer's own token. An installed one gets it at install; an entry you add by hand needs one, or it is not called and counts as unknown: issue it with spot-cli plugin token <id> --kind analyzer.
A setting the plugin's schema marks secret, such as analyzer-misp's MISP_TOKEN, is stored apart under secrets:, encrypted with ENCRYPTION_KEY. It reaches the plugin as a file named in uppercase, here /run/secrets/MISP_TOKEN, never as an environment variable, and the API returns its name only, never its value.
Verify
The dashboard's Health page reports each plugin's status. From the command line:
Editing spot.yaml by hand¶
spot.yaml lives in the spot-config volume. Edit a copy, then write it back whole through the api-gateway: under a temporary name in the volume, renamed into place, so no service ever reads it half copied, and the file keeps its owner:
docker compose cp api-gateway:/app/config/spot.yaml spot.yaml
# edit spot.yaml
docker compose exec -T api-gateway sh -c \
'cat > /app/config/.spot.yaml.new && mv /app/config/.spot.yaml.new /app/config/spot.yaml' \
< spot.yaml
rm spot.yaml
Then reload the configuration (POST /api/v1/config/reload). A plugin whose entry changed takes it when it is restarted (spot-cli plugin restart <id>, the dashboard's Restart, or POST /api/v1/plugins/{plugin_id}/restart). A restart of the api-gateway applies it too. The reload, or the start, records the edit in the history.
When the api-gateway is not running (it does not start on a spot.yaml that does not validate), write the file the same way from a one-off container of its image, user and volume, then start it:
docker compose run --rm --no-deps -T api-gateway sh -c \
'cat > /app/config/.spot.yaml.new && mv /app/config/.spot.yaml.new /app/config/spot.yaml' \
< spot.yaml
docker compose up -d api-gateway
Plugin source bootstrap¶
A fresh deployment gets the signed plugin index, spot-plugin-index, as its plugin source on first start, with no auth and no Git forge required; its catalog is filled by a first refresh (Refresh catalog on the Plugins pages). An upgrade keeps its sources and gains it.
| Variable | Description | Default |
|---|---|---|
SPOT_BOOTSTRAP_DEFAULT_PLUGIN_SOURCE | Add the signed plugin index once | true |
The index is added once: removed, it stays removed, and while the variable is false no start adds it (when). Set to false for air-gapped deployments or operators using a custom source. See The signed plugin index.
Plugin source types¶
A plugin source tells SPOT where to discover and download plugin images. Seven types are supported: the signed index, and six split into two families:
| Type | Family | Lists images via | Fetches OCI labels via |
|---|---|---|---|
index | Signed index | its index.json, signed | the registry of each image, by digest |
gitlab | Git forge | GitLab API | configured registry |
gitea | Git forge | Gitea / Forgejo API | configured registry |
dockerhub | Registry | hub.docker.com/v2/repositories/{ns}/ | registry-1.docker.io |
ghcr | Registry | api.github.com/{users\|orgs}/{ns}/packages | ghcr.io |
gitlab_registry | Registry | GitLab /registry/repositories | configured registry |
gitea_registry | Registry | Gitea /api/v1/packages/{owner} | same host as URL |
Forge sources also surface a Git "view source" link on each catalog card. Registry sources skip the forge entirely and recover the source URL from the org.opencontainers.image.source OCI label when the plugin's Dockerfile sets it.
GHCR requires a Personal Access Token with read:packages scope even for public packages — that's a GitHub quirk, not a SPOT requirement. The other public registries (Docker Hub, Codeberg) work anonymously for public namespaces.
The dashboard's Plugins → Sources → New form pre-fills the URL and registry when you pick a type, and, for an index, the URL alone: an index source has no organisation, registry or token. The same fields are accepted by the REST API at /api/v1/plugin-sources.
Mail Retrievers¶
Mail retrievers are plugins (mail_retriever kind) that ingest emails into the platform ; Postfix milter, future IMAP / Microsoft Graph, etc. They install and configure the same way as analyzers and context providers: from the dashboard's plugin catalog, not via environment variables.
The bundled official retriever is retriever-milter, a milter for an existing Postfix. See Mail integration for the end-to-end setup, mode selection (observe vs enforce), and the headers SPOT adds to delivered messages.
Where the configuration lives¶
Installed mail retrievers are tracked in spot.yaml (/app/config/spot.yaml on the spot-config volume) under plugins.mail_retrievers.<id>. The dashboard writes this file when you install, enable, disable, or change settings. The orchestrator re-reads it per job, so no restart is needed.
Each retriever is a mail connection with a mode: observe, in which SPOT records what would be done with each message, or enforce, in which it is done. A new one is written in observe. Switch it with spot-cli plugin mode <id> enforce or from its page in the dashboard: nothing restarts, the retriever takes the mode from SPOT's answers (see Mail integration). SPOT's routes choose the workflow of its mail (see Routing).
plugins:
mail_retrievers:
retriever-milter:
enabled: true
url: "http://retriever-milter:8000"
mode: observe
settings: {}
Compose profile¶
retriever-milter ships under the optional mail-milter compose profile so a default docker compose up -d doesn't try to start it without configuration. To enable it, add mail-milter to the profiles COMPOSE_PROFILES already lists (spot-cli setup writes cli, and gpu for standard and full):
The milter is a plugin run by the compose file, so it does not get its token at install. Issue it with spot-cli plugin token retriever-milter --kind mail_retriever and set it in .env:
Compose hands it to the milter as the file /run/secrets/spot_plugin_token. With the profile on and the variable unset, docker compose up -d stops at the milter.
The RETRIEVER_MILTER_* variables (bind address, allowed clients, timeout and verdict actions) are listed in .env.example and explained in Mail integration. No variable sets the milter's mode or workflow: SPOT does.
Authentication results¶
SPOT counts the Authentication-Results headers (SPF, DKIM, DMARC) of the authserv-ids listed in mail.trusted_authserv_ids of spot.yaml only: your own MTAs' ids, the first word of the headers they add. The list is empty by default, so no result counts until you set it. Each of these MTAs must remove the incoming Authentication-Results headers that carry its id (RFC 8601, section 5), or a sender can forge one.
Edit spot.yaml as in Editing spot.yaml by hand, then reload the configuration (POST /api/v1/config/reload): the api-gateway reads the list for each message. If the volume holds no spot.yaml yet, write one with the mail: section alone: the shipped defaults give the rest. Routes on sender domains need the list: see Routing.
Routing¶
Routes choose the workflow each email runs, recipient groups name the recipients routes match on, and the shadow runs candidate workflows beside production, to compare. Three sections of spot.yaml hold them. Admins edit them on the dashboard's Routing page, which analysts read, or print and replace the three together with the CLI:
spot-cli routing show > routing.yaml # analysts and admins
spot-cli routing apply routing.yaml # admins: replaces all three
rm routing.yaml
The CLI sees the directory you run it from, read-only, so apply reads ./routing.yaml. A save applies from the next email, without a restart, and the history records it.
recipient_groups:
finance: ["cfo@example.com", "@finance.example.com"]
routing:
- name: finance
match: {recipient_groups: [finance]}
workflow: strict-workflow
- name: milter
match: {sources: [retriever-milter]}
workflow: fast-workflow
timeout_ms: 15000
- name: vendors
match: {sender_domains: ["vendor.example", "*.vendor.example"]}
workflow: default-workflow
mode: observe
shadow:
- workflow: candidate-workflow
match: {sources: [retriever-milter]}
- Routes. The first route whose match holds chooses the workflow, and an email no route takes runs the default workflow (
default_workflow_id). Within a match, every criterion given must hold, and any of its values: an empty match takes every email.sourcesnames retrievers, orapifor the emails submitted to SPOT. A workflow a user names, for a submission or a re-analysis, is not routed. A route'stimeout_ms(1 000 to 300 000) caps how long a retriever that waits for the verdict, such as the milter, waits. A route'smodeisenforceby default, which follows its connection, orobserve, which keeps its emails in observe whatever the connection says. - Recipient groups. An entry is an address (
cfo@example.com) or a whole domain (@example.com, not its subdomains). A route matches when one of the email's recipients is in a group it names: the SMTP envelope's recipients when the retriever sends them, as the milter does, else theToandCcaddresses. The groups list staff addresses: analysts and admins read them, viewers do not. - Sender domains,
example.comor*.example.comfor its subdomains alone, match theFromdomain only when a DMARC result of one ofmail.trusted_authserv_idspassed for it (see Authentication results): a forged or untrusted result matches nothing. While that list is empty, SPOT refuses to save a route with sender domains, and one written by hand never matches, which the api-gateway logs once per load. - The shadow. A shadow workflow runs again on the emails its match takes, after production has decided, and SPOT keeps where its verdict differs from production's (
GET /api/v1/workflows/{workflow_id}/shadow). Production's verdict, its responders and the retriever's answer never change. Each shadow entry adds one workflow run per email it takes, which competes with production's for the analyzers: shadow the mail whose volume they can carry twice. When the shadow cannot keep up, its queue,orchestrator.shadow, drops its oldest work past 1 000 results, never production's.
SPOT refuses a routing that names a workflow that is not loaded, naming the route, and a workflow that a route or the shadow names cannot be deleted. Domains are written as mail carries them: letters, digits and hyphens (_ is refused). Routing (spot.yaml) in the configuration reference gives every rule.
History of spot.yaml¶
Every change of spot.yaml is a commit of history.git, a git repository beside it on the spot-config volume (/app/config/history.git in the api-gateway). Each commit holds the whole file, its author (the user who made the change, or SPOT for its own) and what the change did.
- Reading and rolling back. Admins read the changes on the History of the dashboard's Configuration page, or with
spot-cli config historyandspot-cli config diff <commit>.spot-cli config rollback <commit>shows what a rollback changes and asks first. A rollback is a change of its own, and restores every section of that commit butplugins:: the plugins, their containers, tokens and settings stay as they are. Settings read only at start, such as theintakesection orallowed_origins, need a restart of the api-gateway. What the environment sets stays the environment's: the compose file sets the api-gateway'sLOG_LEVELandALLOWED_HOSTSfrom.env, which a rollback does not change. - One workflow.
spot-cli workflow history <id>lists a workflow's versions,spot-cli workflow restore <id> <commit>saves one of them as its next version, andspot-cli workflow clone <id> <new-id>copies a workflow; the workflow's page in the dashboard has its History. - Edits by hand, as in Editing spot.yaml by hand, are recorded at the next reload (
POST /api/v1/config/reload), by its user, or at the api-gateway's next start, by SPOT. Either first seals the plain secret settings of catalog plugins, so no commit holds them. - Never pruned. The history keeps every value ever saved, the recipient groups' addresses and each author's email included, unlike the emails (
data_retention_days). Keep a manual plugin's secret settings in its sealedsecrets, never insettings: without a catalog entry, SPOT cannot tell that they are secret, and records them as they are. - Backed up with the volume.
spot-cli backupsaves the wholespot-configvolume,history.gitwithspot.yaml, andspot-cli restoreputs both back (see Backup and restore).
The images hold no git binary, but git reads the history anywhere. Delete the copy once read: it holds every value ever saved.
docker compose cp api-gateway:/app/config/history.git history.git
git --git-dir history.git log --stat
When a change cannot be recorded (a full volume, a damaged history.git), it stays written: the api-gateway logs spot.yaml was saved but not recorded in its history: ..., and the next commit records it. When commits keep failing, move history.git aside: the next change starts a new history, whose first commit holds the whole file, and git still reads the old one.
Plugin isolation¶
Plugins are run apart from the platform:
- Containers. The internal
plugin-managerservice runs the plugin containers, throughplugin-docker-proxy, a socket proxy that allows only the Docker calls it makes. Traefik reads Docker through its own read-only proxy,traefik-docker-proxy. No other service mounts the Docker socket. Both proxies run asnobodywith the socket's group, a read-only file system, no capabilities andno-new-privileges. Each exits at start when it cannot open the socket (a wrongDOCKER_GID), and stops when the socket stops answering later, checked every 60 s, so compose restarts it. Plugin containers run with memory and CPU limits (1024 MB and 1 CPU by default), all capabilities dropped andno-new-privileges. - Tokens. Each plugin authenticates with its own token, the file
/run/secrets/spot_plugin_token, limited to the scopes of its kind. Plugins never getSPOT_INTERNAL_API_KEY. - Network. Every plugin SPOT installs runs on
spot-plugins-isolated, an internal network with no route out, whatever its egress class: it reaches the api-gateway, the knowledge service and the analyzer-orchestrator, and its lookups of other names go unanswered (from Docker Engine 25.0.4, SPOT's floor: earlier engines forward them to the host's resolvers, CVE-2024-29018). It reaches the hosts its settings of typeurlname, through the egress gateway, and nothing else: see Reaching configured hosts.
| Variable | Description | Default |
|---|---|---|
DOCKER_GID | Group that owns the Docker socket (stat -c %g /var/run/docker.sock). Both socket proxies run unprivileged with it | 999 |
Reaching configured hosts¶
A plugin of any egress class reaches the hosts its settings of type url name, through the egress gateway, and nothing else. For example:
- provider-employee-dir with
LDAP_URL=ldaps://ldap.corp.examplereachesldap.corp.example:636, and its ldap backend works; - analyzer-misp with
MISP_URL=https://misp.corp.examplereachesmisp.corp.example:443; -
analyzer-llm reaches
ollama:11434by default, the bundled side-car (see The side-car and analyzer-llm). -
The value counts, or the image's default when you set none. An image can therefore give itself a route to the host its default names. Clear the setting (
"") to take that route away. The plugin's page in the dashboard lists what it reaches, under "Reaches", and the API returns it asreach, inhost:portform. - A name, not an address. The port is the URL's, or the scheme's when the URL gives none:
http80,https443,ldap389,ldaps636. A plugin reaches one host per port. The names of SPOT's own services and loopback reach nothing, but for the bundled Ollama's names, which acontentplugin reaches (see The side-car and analyzer-llm). The installer refuses a setting the gateway cannot serve, naming the setting, at install, at update and when the settings change, from the dashboard as from the API. - Plain, not secret. The gateway holds no key, so a url setting a plugin reaches through it is plain: a value given for it as a secret (
--secret, orsecretsin an API write) is refused by name, and a url setting its image markssecret: truereaches nothing. - The connection. The gateway names each plugin that connects by the reverse DNS name of its container on
spot-plugins-reach, and forwards the connection to the one host that plugin reaches on that port, or refuses it. Bytes pass as they are, so TLS runs from the plugin to its host and the certificate's name matches. Whether it connects directly or through the operator's proxy is the next bullet's. - The addresses it refuses. The gateway resolves each host itself and connects to an address it resolved. It never connects to a loopback, link-local (the cloud metadata service,
169.254.169.254, is one), unspecified, multicast or reserved address, nor to four metadata services outside link-local:fd00:ec2::254(AWS over IPv6),100.100.100.200(Alibaba Cloud),fd20:ce::254(Google Cloud over IPv6) and168.63.129.16(Azure's WireServer). A name that resolves to these only is refused, and logged. Nor to an address inside its own networks,spot-plugins-reachandspot-outbound(EGRESS_SUBNET), but for a host of the bundled Ollama's names, and only onspot-outbound: a name that resolves to Ollama is refused for a plugin that may not reach it (see The side-car and analyzer-llm). The Docker host and your other networks stay reachable by a name that resolves to them. - Its limits. A peer address holds at most
EGRESS_MAX_CONNECTIONSconnections through the gateway at once, 32 by default, counted from the moment the gateway accepts each connection, before it names the plugin. A plugin has one address onspot-plugins-reach, so this is the plugin's cap. A connection that moves no byte either way forEGRESS_IDLE_TIMEOUT_Sseconds, 900 by default, is closed: more than analyzer-llm waits for its model. Each refusal is logged with the address or the plugin, and the port. Raise the cap when you raiseMAX_CONCURRENT_JOBSorMAX_CONCURRENT_SYNC_JOBS. The gateway's open-file limit is 65536, its libuv pool for name lookups has 64 threads (UV_THREADPOOL_SIZE) and its memory limit is 256M. - A hand edit of
spot.yamlthat changes or takes away a plugin's host closes its open connections to it, within 5 s. - The logs. The gateway logs each connection it passes or refuses, with the plugin and the host and port, never a setting value:
docker logs spot-egress-gateway. - The gateway. It is the compose service
egress-gateway, run from the plugin-manager's image as its unprivileged user, with a read-only file system, no capabilities andno-new-privileges. It holds no SPOT secret. It sits onspot-plugins-reach, an internal network, and onspot-outbound, which it shares with the bundled Ollama only. Its health check,/health, turns unhealthy when it cannot open a port some plugin reaches; a wrongEGRESS_GATEWAY_ADDRESSis the usual cause. - The operator's proxy. The gateway reads the proxy compose gives it, as Running behind an egress proxy says. A host
noProxylists, the bundled Ollama and the proxy itself are reached directly; any other host, through the proxy, withCONNECT. A proxy that tunnels port 443 only (Squid's default) refuses a host on another port: list that host innoProxy, or let the proxy tunnel that port. It tunnels through anhttp://proxy only. A proxy variable it cannot use, such as anhttps://or SOCKS URL, turns/healthunhealthy, naming it, and refuses only the hosts that would go through it;spot-cli upgradethen fails waiting for the gateway's health, so check the URL first. - Hosts not known in advance. A plugin reaches no host its settings do not name. One that must fetch others names a forward proxy in a url setting, and that proxy's policy decides.
- An address. A host without a DNS name is named for the gateway in
docker-compose.override.yml, then by that name in the setting:
The server's TLS certificate must hold that name.
| Variable | Description | Default |
|---|---|---|
PLUGIN_REACH_SUBNET | Subnet of spot-plugins-reach | 192.168.231.0/24 |
EGRESS_GATEWAY_ADDRESS | The gateway's fixed address on it, inside the subnet | 192.168.231.2 |
EGRESS_SUBNET | Subnet of spot-outbound, the gateway's own way out, which the gateway refuses to forward to | 192.168.230.0/24 |
EGRESS_MAX_CONNECTIONS | The most connections one plugin holds through the gateway at once; above 0, or the gateway fails at start, naming the variable | 32 |
EGRESS_IDLE_TIMEOUT_S | Seconds a connection may move no byte either way before the gateway closes it; above 0, or the gateway fails at start, naming the variable | 900 |
Change PLUGIN_REACH_SUBNET and EGRESS_GATEWAY_ADDRESS together, the address inside the subnet, when the subnet collides with a network of the host. Change them before you install a plugin with a reach: since 2.0.2, compose cannot re-create the network while plugin containers are attached to it. Change EGRESS_SUBNET too when it collides with one.
The compose networks:
| Network (Docker name) | Members |
|---|---|
spot_spot-network (fixed subnet, below) | Traefik, api-gateway, knowledge, analyzer-orchestrator, mail-orchestrator, plugin-manager, the dashboard, the CLI, Ollama |
spot_spot-db-network (internal) | Postgres, Redis, RabbitMQ, the migration job, api-gateway, knowledge, analyzer-orchestrator, mail-orchestrator |
spot_spot-docker (internal) | plugin-manager, plugin-docker-proxy |
spot_spot-traefik-docker (internal) | Traefik, traefik-docker-proxy |
spot-plugins | the milter, plugins you run yourself, api-gateway, knowledge, analyzer-orchestrator |
spot-plugins-isolated (internal) | installed plugins, api-gateway, knowledge, analyzer-orchestrator |
spot-plugins-reach (internal, PLUGIN_REACH_SUBNET) | installed plugins with a reach, the egress gateway |
spot-outbound (EGRESS_SUBNET) | the egress gateway, the bundled Ollama |
spot_spot-network has a fixed subnet, DOCKER_NETWORK_SUBNET in .env (192.168.229.0/24 by default). The api-gateway trusts X-Forwarded-For from that subnet only, which holds the core services, Traefik and the dashboard, so it rate-limits and audits each caller by their own address. Plugins sit outside it and cannot set their own. Change the subnet when it collides with a network of the host.
The dashboard trusts the address Traefik passes on, and Traefik replaces any X-Forwarded-For a client sends. Keep it so: do not set Traefik's forwardedHeaders.trustedIPs, for a CDN in front of SPOT for example, unless you also set the dashboard's FORWARDED_ALLOW_IPS to DOCKER_NETWORK_SUBNET. Otherwise a client can choose the address the dashboard and the gateway see.
Traefik does not route /internal/* on api.DOMAIN: those are the plugin routes of the api-gateway, reachable from spot-plugins and spot-plugins-isolated only.
A plugin you run yourself joins the external network spot-plugins, without SPOT_INTERNAL_API_KEY, and mounts its token, issued with spot-cli plugin token <id> --kind <kind>. See Plugins you run yourself for the compose snippet.
Plugin memory and CPU¶
A plugin container gets 1024 MB and 1 CPU unless its image asks for more with the spot.plugin.resources label; no SPOT analyzer image carries it yet. analyzer-nlp (torch, transformers) may need more. A plugin killed for lack of memory is restarted, which clears its OOMKilled flag: its restart count (docker inspect -f '{{.RestartCount}}' spot-analyzer-nlp) is the first sign, and the kernel log keeps the kill (see Memory limits).
Raise the limits with spot-cli plugin resources, or from the plugin's page in the dashboard (its Resources panel). An option left out keeps its value, and the plugin restarts to take them; a disabled plugin takes them when it is started:
spot-cli plugin resources analyzer-nlp --memory-mb 3072 --cpus 2
spot-cli plugin resources analyzer-nlp # the override, the image's request and what applies
spot-cli plugin resources analyzer-nlp --reset # back to the image's request, else the default
The override is resources: on the plugin's entry in spot.yaml, and survives a plugin update.
Egress and consent¶
Each plugin image declares what it sends outside SPOT, its egress class (spot.plugin.egress): none, indicators (URLs, domains, addresses, hashes) or content (the email's content). An image without the label counts as content. The catalog shows the class.
Installing a content plugin, or updating to a content version, needs an admin's consent: --consent-content on spot-cli plugin install and plugin update, or "consent_to_content": true in the API request; without it the install answers 409. The installer records it on the plugin's entry (consent:) and does not ask again. Of the SPOT plugins, analyzer-llm and retriever-milter are content, analyzer-misp is indicators, and the others are none. The class says what a plugin sends out and whether an admin must consent. It no longer decides where the plugin runs: every plugin runs on spot-plugins-isolated. Of the SPOT plugins, only a content one reaches the bundled Ollama, and so does a plugin with no recorded class, which counts as content. A manual entry records no egress class and no consent.
SPOT runs plugin contract 2 only. The catalog lists an image of an earlier contract with the reason it refuses it, and installing or updating to it answers 409.
Knowledge Store (RAG layer)¶
The knowledge service embeds documents with Ollama. Operators have two choices ; point at an external Ollama, or enable the bundled side-car under a compose profile.
| Variable | Description | Default |
|---|---|---|
SPOT_INTERNAL_API_KEY | Key the core services authenticate each other with. Never give it to a plugin: plugins hold their own token | None - Required |
OLLAMA_URL | Ollama endpoint for embeddings | http://ollama:11434 |
EMBEDDING_MODEL | Model pulled and used for embeddings | bge-m3 |
COMPOSE_PROFILES | Comma-separated compose profiles. Add ollama to enable the bundled side-car, or gpu for its GPU variant | Empty; spot-cli setup writes cli, and cli,gpu for standard and full |
OLLAMA_EXTRA_MODELS | Space-separated extra models to pre-pull on the bundled side-car (e.g. qwen2.5:1.5b) | Empty |
OLLAMA_CONTEXT_LENGTH | Context, in tokens, of every model the bundled side-car serves (see The side-car and analyzer-llm) | 16384 |
OLLAMA_MEMORY_LIMIT | Memory limit of the bundled side-car (see Ollama's limit) | 12G |
Option 1 ; External Ollama¶
Make sure the model is pulled on that host: ollama pull bge-m3.
Option 2 ; Bundled side-car¶
Add ollama to the profiles COMPOSE_PROFILES already lists:
# /opt/spot/deploy/.env
COMPOSE_PROFILES=cli,ollama
# optional: pre-pull the AI-assistant model too
OLLAMA_EXTRA_MODELS=qwen2.5:1.5b
Then docker compose up -d starts:
spot-ollama; the server (pinned image, persistentollama_datavolume).spot-ollama-init; one-shot that pulls$EMBEDDING_MODELand any$OLLAMA_EXTRA_MODELS, then exits.ollama pullis idempotent, so re-running it is cheap.
Watch the first pull (≈ 1.5 GB for bge-m3):
The GPU variant¶
COMPOSE_PROFILES=gpu, instead of ollama, starts spot-ollama-gpu: the same side-car on the host's NVIDIA GPUs, every one reserved. It answers as ollama on both networks, so the knowledge service and analyzer-llm need no other setting, and ollama-init pulls the models into it. Enable gpu or ollama, never both: both would answer as ollama. The two share the ollama_data volume, so the models one pulled are there for the other. spot-cli setup --profile standard and --profile full enable it.
It needs the NVIDIA driver and the NVIDIA Container Toolkit: docker info -f '{{json .Runtimes}}' lists an nvidia runtime, which spot-cli setup checks. OLLAMA_MEMORY_LIMIT limits the host memory it takes, not the GPU's.
Untested on GPU hardware
The GPU variant was checked with docker compose config only: it has not run on a GPU host yet. Report what you find on one.
The side-car and analyzer-llm¶
analyzer-llm's default LLM_URL, http://ollama:11434/v1, is this side-car. analyzer-llm reaches it through the egress gateway, on spot-outbound, which the side-car and the gateway share. A plugin of class none or indicators is refused a url naming it, and a name that resolves to it is refused for them too, since Ollama's API pulls from and pushes to any registry. Pull its model too: add llama3.1, its default LLM_MODEL, to OLLAMA_EXTRA_MODELS, or run docker exec spot-ollama ollama pull llama3.1.
The side-car gives every model it serves a context of OLLAMA_CONTEXT_LENGTH tokens, 16384 by default. analyzer-llm's largest prompt is LLM_MAX_BYTES + LLM_MAX_TOKENS + 3400 tokens, 12400 with its defaults, and the context must hold it, or the server may drop part of it. The context costs memory while a model is loaded, on top of its weights. For llama3.1 (8B, 4.9 GB in its default quantization) the estimate is 128 KiB per token: 2 GiB at 16384 tokens, about 7 GB with the weights. It follows from the model's shape (32 layers, 8 key-value heads of 128 dimensions, an f16 cache), not from a measurement.
To use less, lower the context and analyzer-llm's LLM_MAX_BYTES together, so that LLM_MAX_BYTES + LLM_MAX_TOKENS + 3400 stays within the context. For example, 8192 tokens (1 GiB of cache for llama3.1) with LLM_MAX_BYTES=3500:
Ollama's limit¶
The bundled side-car is limited to OLLAMA_MEMORY_LIMIT, 12G by default, and never needs less than the models it serves at once. 12G holds llama3.1 8B, analyzer-llm's default model, with its 16384-token context, and bge-m3: about 4.9 GB of weights, 2 GiB of cache and 1.2 GB for bge-m3, above Ollama's documented 8 GB for models of 7B parameters. That figure is an estimate: the 8B model was not measured.
A smaller model needs less. On the test instance, with the rest of the stack stopped, Ollama peaked at 4828 MiB (4.7 GiB) holding phi4-mini (3.8B) with a 16384-token context and bge-m3, sampled every second: an embedding with bge-m3, then a request of analyzer-llm's largest size to phi4-mini. 8G, one and a half times that peak rounded up to a whole GiB, is enough for phi4-mini. On that instance's 4 CPUs the phi4-mini request did not finish within an hour: without a GPU, a large model answers slowly, which is why spot-cli setup's standard profile runs it on the GPU variant.
Raise the limit for a larger model or a longer context. When the side-car runs out of memory, the kernel kills its model runner, which the kernel log keeps (see Memory limits), while the server goes on: docker inspect -f '{{.State.OOMKilled}}' spot-ollama shows true, and analyzer-llm's verdicts are unknown meanwhile:
Then docker compose up -d ollama.
Verifying¶
The knowledge page at https://$DOMAIN/knowledge renders a readiness banner listing each required component ; knowledge service, embedding backend, enabled context providers. Any red row explains how to fix the specific gap. The dashboard calls GET /api/v1/knowledge/readiness on the api-gateway for this; operators can curl it directly for scripting:
Email generator (Ollama-powered)¶
Separate from the Knowledge Store above ; this powers the dashboard's email generator at /generator, which produces synthetic phishing or benign emails on demand for workflow testing and training. It is not a chat assistant. Disabled by default; if OLLAMA_HOST is empty the generator page falls back to its built-in static templates and the AI generation form is hidden.
Re-uses the bundled side-car if enabled (set OLLAMA_HOST=http://ollama:11434), or points at any external Ollama. The model must be pulled on whichever instance you target ; docker exec spot-ollama ollama pull qwen2.5:1.5b, or add qwen2.5:1.5b to OLLAMA_EXTRA_MODELS so the bundled init job pre-pulls it.
| Variable | Description | Default |
|---|---|---|
OLLAMA_HOST | Ollama server URL ; empty disables the AI generation form | Empty (disabled) |
OLLAMA_MODEL | Model used for generation (small instruct models work well) | qwen2.5:1.5b |
OLLAMA_TIMEOUT | Per-request timeout in seconds | 180 |
Admin Bootstrap¶
A fresh database has zero users, so nobody can log in. The bootstrap runs once at api-gateway startup, creates the configured admin account, and lets you sign in to the dashboard. After that, all user management happens in the UI (or the /api/v1/users endpoints).
| Variable | Description | Default |
|---|---|---|
SPOT_BOOTSTRAP_ADMIN | Enable admin creation on startup | false |
SPOT_ADMIN_USERNAME | Admin username | admin |
SPOT_ADMIN_PASSWORD | Admin password (min 16 chars, must contain upper, lower, digit) | Empty - Required when bootstrap enabled |
SPOT_ADMIN_EMAIL | Admin email | admin@example.com |
When it runs¶
The bootstrap fires only when SPOT_BOOTSTRAP_ADMIN=true and the configured SPOT_ADMIN_USERNAME does not yet exist. If a user with the same username already exists ; whether created by a previous bootstrap or manually in the UI ; the bootstrap is a no-op and the existing password / role / email are left untouched. There is no "force reset" mode; to recover a lost admin password, use the operator-only password reset flow described in the admin runbook.
Password requirements¶
The api-gateway rejects passwords that don't meet the policy and the service will fail to start with password does not meet complexity requirements. Generate a compliant random password with:
python3 -c "import secrets, string; alphabet = string.ascii_letters + string.digits; print(''.join(secrets.choice(alphabet) for _ in range(24)))"
After first start¶
Once you have signed in and confirmed the admin account works, set SPOT_BOOTSTRAP_ADMIN=false and remove SPOT_ADMIN_PASSWORD from .env. Leaving the bootstrap enabled is harmless functionally (the existing user is preserved on subsequent starts) but it leaves a plain-text password in .env indefinitely. Apply the change with:
System Update Notifications¶
The api-gateway reads the version tags of the spot repository on Codeberg, at most once an hour, and the dashboard shows admins a banner when a newer release is out. Detection only ; see UPGRADING.md for the actual upgrade flow.
| Variable | Description | Default |
|---|---|---|
SPOT_UPDATE_CHECK_ENABLED | Poll Codeberg and show the banner | true |
SPOT_DEPLOY_VERSION | Set by docker-compose.yml to the release it ships. Do not override. | unknown |
Local customisations: docker-compose.override.yml¶
docker-compose.yml is part of the release and replaced by every upgrade. Editing it means losing your changes on the next upgrade ; and spot-cli upgrade refuses to run while it differs from the running release.
For all stack customisations (extra services, volumes, ports, resource limits), use docker-compose.override.yml next to docker-compose.yml. Compose merges it automatically, and the spot repository does not track it, so spot-cli upgrade, which checks the tracked files only, leaves it be.
# docker-compose.override.yml ; operator-owned, not in git
services:
api-gateway:
environment:
MY_EXTRA_SETTING: "value"
volumes:
- /opt/spot/custom:/opt/custom
Apply: docker compose up -d. Inspect the merged config: docker compose config.
The System Updates page will warn (red banner) if the local docker-compose.yml differs from the upstream copy at the running version ; that's the helper telling you to move customisations into docker-compose.override.yml before upgrading.
Memory limits¶
Every service has a memory limit. Each measured service's is at least twice its peak on a test instance (4 vCPUs, 8 GB), under a load of 400 ingests on an analyzer-nlp workflow with the dashboard in use and a provider's sync, or as the table says. Two were not measured: retriever-milter's is what its image declares, and Ollama's is an estimate (see Ollama's limit):
| Service | Limit | Peak measured |
|---|---|---|
| traefik | 256M | 46 MiB |
| traefik-docker-proxy, plugin-docker-proxy | 64M | 17 MiB |
| postgres | 1G | 191 MiB |
| redis | 512M | 13 MiB |
| rabbitmq | 1G | 239 MiB |
| migration | 512M | 65 MiB |
| api-gateway | 3328M | 245 MiB (its limit follows the intake settings, see Intake and api-gateway memory) |
| plugin-manager | 256M | 59 MiB |
| egress-gateway | 256M | 57 MiB, passing 50 connections to a model server, 14 at once, each answer 3 s in coming; 56 MiB idle |
| analyzer-orchestrator | 1536M | 581 MiB, running 14 analyses at once, with two analyzers in their stage, of a message whose view is 5.2 MiB (the largest view is about 6.8 MiB); 170 MiB under the load above |
| mail-orchestrator | 256M | 60 MiB |
| knowledge | 512M | 60 MiB |
| ollama | OLLAMA_MEMORY_LIMIT, 12G | see Ollama's limit |
| ollama-init | 128M | 12 MiB, pulling phi4-mini |
| retriever-milter | 1024M | what its image declares, see Mail integration |
| cli | 256M | 38 MiB |
| web-dashboard | 512M | 58 MiB |
A container that passes its limit is killed, and Docker restarts it, which clears its OOMKilled flag: that flag stays true only when the kill spared the container's main process, as with Ollama's model runner. The first sign is the restart count. The kernel log keeps each kill: its time and the process killed (python, beam.smp for RabbitMQ, postgres, ollama), and, on the line just before, the container's id in oom_memcg=, which docker inspect -f '{{.Name}}' <id> names:
docker inspect -f '{{.Name}} restarts={{.RestartCount}} oomkilled={{.State.OOMKilled}}' $(docker compose ps -aq)
sudo journalctl -k --since -24h | grep -i -B1 'memory cgroup out of memory'
Within minutes of a kill, docker events --since 10m --until "$(date +%s)" --filter event=oom --format '{{.Actor.Attributes.name}}' names the container too, but no later: Docker keeps only its last 256 events, minutes of a running stack, and loses them when it restarts.
Raise the limit of a container killed for lack of memory in docker-compose.override.yml:
RabbitMQ sizes its memory alarm, which blocks publishers until memory falls, from the memory it is given: RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS gives it its 1G (total_memory_available_override_value 1073741824), and the alarm sets in at half of it. Raise both together: the limit in docker-compose.override.yml and that value in the same service's environment, in bytes.
Plugin containers have limits of their own: see Plugin memory and CPU.
Logging¶
| Variable | Description | Default |
|---|---|---|
LOG_LEVEL | Log level (DEBUG, INFO, WARNING, ERROR) | INFO |
LOG_FORMAT | json, one object per line with the correlation ID, or text for development; any other value stops the core services and the milter at start | json |
LOG_MAX_SIZE | Size at which a service's log file is rotated | 10m |
LOG_MAX_FILES | Log files kept for each service | 5 |
The core services log JSON lines: Monitoring shows how to read them and follow one request. Every service logs with Docker's json-file driver, rotated: at most LOG_MAX_SIZE x LOG_MAX_FILES, 50 MB, for each service by default. The one exception is cli, which has no log driver: what the CLI prints, such as a plugin token, reaches only the terminal that ran it, never Docker's logs. To send a service's logs to a central driver instead, override its logging in docker-compose.override.yml; another driver takes none of the rotation options:
# docker-compose.override.yml
services:
api-gateway:
logging:
driver: syslog
options:
syslog-address: "udp://logs.example.com:514"
DNS Requirements¶
Ensure these DNS records point to your server:
DOMAIN(e.g.,spot.example.com) -> Your server IPapi.DOMAIN(e.g.,api.spot.example.com) -> Your server IPtraefik.DOMAIN(optional, e.g.,traefik.spot.example.com) -> Your server IP
Let's Encrypt will automatically obtain certificates for these domains.
Running behind an egress proxy¶
When the host reaches the internet only through an HTTP proxy, two separate Docker settings are involved. SPOT then handles core and plugin traffic itself; the noProxy entries listed below are your job.
Docker daemon proxy¶
The daemon pulls the images. Configure it in a systemd drop-in, for example /etc/systemd/system/docker.service.d/http-proxy.conf:
[Service]
Environment="HTTP_PROXY=http://proxy.example:3128"
Environment="HTTPS_PROXY=http://proxy.example:3128"
Environment="NO_PROXY=localhost,127.0.0.1"
Then run systemctl daemon-reload && systemctl restart docker.
Docker client proxies¶
Compose injects the proxies.default entry of ~/.docker/config.json into every container it creates, as HTTP_PROXY, HTTPS_PROXY and NO_PROXY (and their lowercase forms):
{
"proxies": {
"default": {
"httpProxy": "http://proxy.example:3128",
"httpsProxy": "http://proxy.example:3128",
"noProxy": "localhost,127.0.0.1,.corp.example"
}
}
}
Recreate the containers after changing it: docker compose up -d --force-recreate.
What SPOT does with them¶
- Core services call each other and the plugins directly, never through the proxy.
- Plugins get no proxy variable and no credentials file. They run on an internal network with no route out, and the egress gateway is their only way out (see Reaching configured hosts).
- The egress gateway reads the proxy,
HTTPS_PROXYfirst, elseHTTP_PROXY, andNO_PROXY. It reaches a hostNO_PROXYlists, the bundled Ollama and the proxy itself directly; every other host through the proxy, withCONNECT, so TLS still runs from the plugin to its host.ALL_PROXYis not read. A value without a scheme (proxy.example:3128) is read ashttp://proxy.example:3128, as HTTP clients read it. - Credentials in the proxy URL (
http://user:pass@...) go to the proxy inProxy-Authorization, and never in a log. Compose injects the URL, credentials included, into the environment of every container it creates, wheredocker inspectshows it: prefer a proxy account restricted by source IP. - The gateway tunnels through an
http://proxy only. A proxy variable it cannot use (anhttps://or SOCKS URL, a value that is not a proxy URL, a user and password that are not percent-encoded) turns its/healthunhealthy, naming the variable, never its value, andspot-cli upgradethen fails waiting for it: check the URL before upgrading. It refuses only the hosts that would go through that proxy. - The plugin-manager ignores every proxy variable: its Docker calls go straight to
plugin-docker-proxy. - Traefik ignores
HTTP_PROXY: its backends and its Docker endpoint (traefik-docker-proxy) are plainhttp://calls on internal networks.HTTPS_PROXYstays for Let's Encrypt.
What noProxy should contain¶
The dashboard, the CLI (docker compose run cli), the ollama containers, the milter and the container healthchecks still honour the proxy variables and call platform hosts by name. Put this in noProxy:
Add your internal domains and addresses: the hosts SPOT must reach without the proxy, such as an internal mail server or identity provider. Plugin container names are not needed. The hosts a plugin must reach without the proxy, such as an internal MISP or LDAP server, belong here too: the egress gateway reaches them directly only when noProxy lists them, and the proxy must tunnel their port otherwise (Squid's default tunnels 443 only). The gateway matches noProxy by name and domain only: misp.corp.example for that host, corp.example or .corp.example for every host in that domain, never a CIDR, an address or a *. prefix.
0.0.0.0 matters for Ollama; see Troubleshooting for the symptom when it is missing.
An external Ollama (OLLAMA_URL) is called by core without the proxy. It must be reachable directly from the host.
Checking¶
docker exec spot-analyzer-orchestrator env | grep -i proxy
docker exec spot-egress-gateway env | grep -i proxy # shows the credentials, as for every core container
docker logs spot-egress-gateway
Run these as the user that runs compose. When compose runs under sudo, the settings come from /root/.docker/config.json, not ~/.docker/config.json.
Upgrading: see Upgrading.