Skip to content

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, requires TRAEFIK_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:

htpasswd -nb admin yourpassword

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:

# docker-compose.override.yml
services:
  api-gateway:
    deploy:
      resources:
        limits:
          memory: 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.

plugins:
  analyzers:
    analyzer-nlp:
      enabled: true
      url: "http://analyzer-nlp:8000"
      settings: {}

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:

docker compose logs analyzer-orchestrator | grep -i analyzer

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):

# /opt/spot/deploy/.env
COMPOSE_PROFILES=cli,mail-milter

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:

# /opt/spot/deploy/.env
RETRIEVER_MILTER_TOKEN=spot_pt_...

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.

mail:
  trusted_authserv_ids: ["mx.example.com"]

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. sources names retrievers, or api for the emails submitted to SPOT. A workflow a user names, for a submission or a re-analysis, is not routed. A route's timeout_ms (1 000 to 300 000) caps how long a retriever that waits for the verdict, such as the milter, waits. A route's mode is enforce by default, which follows its connection, or observe, 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 the To and Cc addresses. The groups list staff addresses: analysts and admins read them, viewers do not.
  • Sender domains, example.com or *.example.com for its subdomains alone, match the From domain only when a DMARC result of one of mail.trusted_authserv_ids passed 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 history and spot-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 but plugins:: the plugins, their containers, tokens and settings stay as they are. Settings read only at start, such as the intake section or allowed_origins, need a restart of the api-gateway. What the environment sets stays the environment's: the compose file sets the api-gateway's LOG_LEVEL and ALLOWED_HOSTS from .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, and spot-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 sealed secrets, never in settings: without a catalog entry, SPOT cannot tell that they are secret, and records them as they are.
  • Backed up with the volume. spot-cli backup saves the whole spot-config volume, history.git with spot.yaml, and spot-cli restore puts 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.

docker compose exec api-gateway mv /app/config/history.git /app/config/history.git.old

Plugin isolation

Plugins are run apart from the platform:

  • Containers. The internal plugin-manager service runs the plugin containers, through plugin-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 as nobody with the socket's group, a read-only file system, no capabilities and no-new-privileges. Each exits at start when it cannot open the socket (a wrong DOCKER_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 and no-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 get SPOT_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 type url name, 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.example reaches ldap.corp.example:636, and its ldap backend works;
  • analyzer-misp with MISP_URL=https://misp.corp.example reaches misp.corp.example:443;
  • analyzer-llm reaches ollama:11434 by 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 as reach, in host:port form.

  • A name, not an address. The port is the URL's, or the scheme's when the URL gives none: http 80, https 443, ldap 389, ldaps 636. 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 a content plugin 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, or secrets in an API write) is refused by name, and a url setting its image marks secret: true reaches 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) and 168.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-reach and spot-outbound (EGRESS_SUBNET), but for a host of the bundled Ollama's names, and only on spot-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_CONNECTIONS connections 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 on spot-plugins-reach, so this is the plugin's cap. A connection that moves no byte either way for EGRESS_IDLE_TIMEOUT_S seconds, 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 raise MAX_CONCURRENT_JOBS or MAX_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.yaml that 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 and no-new-privileges. It holds no SPOT secret. It sits on spot-plugins-reach, an internal network, and on spot-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 wrong EGRESS_GATEWAY_ADDRESS is the usual cause.
  • The operator's proxy. The gateway reads the proxy compose gives it, as Running behind an egress proxy says. A host noProxy lists, the bundled Ollama and the proxy itself are reached directly; any other host, through the proxy, with CONNECT. A proxy that tunnels port 443 only (Squid's default) refuses a host on another port: list that host in noProxy, or let the proxy tunnel that port. It tunnels through an http:// proxy only. A proxy variable it cannot use, such as an https:// or SOCKS URL, turns /health unhealthy, naming it, and refuses only the hosts that would go through it; spot-cli upgrade then 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:
services:
  egress-gateway:
    extra_hosts:
      - "misp.lan:10.1.2.3"

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.

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

# /opt/spot/deploy/.env
OLLAMA_URL=http://my-ollama-host:11434
EMBEDDING_MODEL=bge-m3

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, persistent ollama_data volume).
  • spot-ollama-init ; one-shot that pulls $EMBEDDING_MODEL and any $OLLAMA_EXTRA_MODELS, then exits. ollama pull is idempotent, so re-running it is cheap.

Watch the first pull (≈ 1.5 GB for bge-m3):

docker compose logs -f ollama-init

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.

# /opt/spot/deploy/.env
COMPOSE_PROFILES=cli,gpu
OLLAMA_EXTRA_MODELS=llama3.1:8b

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:

# /opt/spot/deploy/.env
OLLAMA_CONTEXT_LENGTH=8192
docker compose up -d ollama
spot-cli plugin settings analyzer-llm --set 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:

# /opt/spot/deploy/.env
OLLAMA_MEMORY_LIMIT=16G

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:

curl -sS -H "Authorization: Bearer $TOKEN" \
    https://api.$DOMAIN/api/v1/knowledge/readiness | jq

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:

docker compose up -d api-gateway

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:

# docker-compose.override.yml
services:
  postgres:
    deploy:
      resources:
        limits:
          memory: 2G

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 IP
  • api.DOMAIN (e.g., api.spot.example.com) -> Your server IP
  • traefik.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_PROXY first, else HTTP_PROXY, and NO_PROXY. It reaches a host NO_PROXY lists, the bundled Ollama and the proxy itself directly; every other host through the proxy, with CONNECT, so TLS still runs from the plugin to its host. ALL_PROXY is not read. A value without a scheme (proxy.example:3128) is read as http://proxy.example:3128, as HTTP clients read it.
  • Credentials in the proxy URL (http://user:pass@...) go to the proxy in Proxy-Authorization, and never in a log. Compose injects the URL, credentials included, into the environment of every container it creates, where docker inspect shows it: prefer a proxy account restricted by source IP.
  • The gateway tunnels through an http:// proxy only. A proxy variable it cannot use (an https:// or SOCKS URL, a value that is not a proxy URL, a user and password that are not percent-encoded) turns its /health unhealthy, naming the variable, never its value, and spot-cli upgrade then 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 plain http:// calls on internal networks. HTTPS_PROXY stays 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:

localhost,127.0.0.1,0.0.0.0,api-gateway,ollama

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.