Skip to content

Deployment

This page is the checklist for putting SuckerPot on the internet. Follow the steps in order. Do not skip the firewall, the snapshot or the verification.

Read Getting started first, and the Security page for the threat model.

  • Only the sensor ports face the internet. Every other port stays private.
  • The dashboard is reached through a reverse tunnel. The tunnel (for example cloudflared) connects from a machine you control to the dashboard port. The dashboard port is never open to everyone.
  • The host’s own admin SSH runs on a port that no sensor uses. It is never exposed to the internet.
  • Filters work upstream of the host. Docker publishes ports in its own firewall rules, so a host INPUT rule does not protect a published port. Filter at the router or hypervisor, or in the DOCKER-USER chain.
  • Containers must not reach the host or the LAN. The host firewall below enforces that.
  • A broken setup must fail closed. Test the rules before you open the router.

Do these in order. Tick each item only after you have checked it.

  1. The host is a dedicated VM or machine (see the warning in Getting started), with Docker and the Compose plugin up to date.
  2. Generate fresh secrets (see Secrets), the dashboard SESSION_SECRET and the admin password hash included. No change-me is left in .env.
  3. Log out of any AI coding assistant, IDE sync and cloud CLI on the host. Revoke or rotate any git or API token that was used on the host. Check that no key sits in a file that other users can read.
  4. Move the host’s admin SSH to a port that no sensor or dashboard uses. Allow it only from your management network.
  5. Run the port guard (just ports).
  6. Install and verify the host firewall. If you use the VPN, set up anonymous egress and run just egress-check.
  7. Set DASHBOARD_BIND and filter the dashboard port upstream.
  8. Set API_ALLOWED_ORIGINS if the tunnel terminates TLS.
  9. If you will turn on FEEDS_ENABLED, set AUTH_TRUSTED_PROXIES to the tunnel’s address first. Without it every feed client looks like the tunnel, so the rate limits and the failure lockout are shared by everyone (see Defend).
  10. Set INDICATORS_IGNORE to your public IP (or CIDR block) and the host names you answer to, so your own addresses are never recorded as attack infrastructure. API_ALLOWED_HOSTS and API_ALLOWED_ORIGINS are added automatically.
  11. Keep API_ENABLE_DOCS=false.
  12. Start the stack and check it: just up, then docker compose ps. Run just ssh-test.
  13. Take a snapshot.
  14. Forward the ports.
  15. Verify from outside.

Every secret must be at least 24 characters long, and no two may be equal. The stack refuses to start otherwise, and names the variable (never its value).

Generate each one separately:

Terminal window
openssl rand -base64 32
SecretPurpose
POSTGRES_PASSWORDDatabase owner. Schema changes only.
POSTGRES_INGEST_PASSWORDRole suckerpot_ingest
POSTGRES_API_PASSWORDRole suckerpot_api (read-only)
REDIS_PASSWORDRedis admin user (operators only)
REDIS_HEALTH_PASSWORDRedis health user
REDIS_INGEST_PASSWORDRedis ingest user
REDIS_SENSOR_SSH_PASSWORDRedis sensor-ssh user
REDIS_SENSOR_RDP_PASSWORDRedis sensor-rdp user
REDIS_SENSOR_TELNET_PASSWORDRedis sensor-telnet user
REDIS_SENSOR_WEB_PASSWORDRedis sensor-web user
REDIS_SENSOR_REDIS_PASSWORDRedis sensor-redis user
REDIS_SENSOR_DOCKER_PASSWORDRedis sensor-docker user
REDIS_SENSOR_MYSQL_PASSWORDRedis sensor-mysql user
REDIS_SENSOR_MSSQL_PASSWORDRedis sensor-mssql user
REDIS_SENSOR_ADB_PASSWORDRedis sensor-adb user
REDIS_SENSOR_SMB_PASSWORDRedis sensor-smb user
REDIS_SENSOR_SIP_PASSWORDRedis sensor-sip user
REDIS_SENSOR_SYNOLOGY_PASSWORDRedis sensor-synology user
REDIS_DEMO_PASSWORDRedis demo user

Keep .env readable by the owner only (chmod 600 .env). Never commit it. SUCKERPOT_ALLOW_DEFAULT_SECRETS=1 turns the checks off. It exists for CI only. Never set it on a real host.

Rotate every secret before the first exposure, after any suspected leak, and from time to time. Passwords are read at container start, so each change needs a restart. Take a snapshot or a database dump first.

Redis passwords (REDIS_*):

  1. Edit the values in .env.

  2. Recreate everything that talks to Redis. Redis rewrites its ACL file on every start.

    Terminal window
    docker compose up -d --force-recreate redis ingest sensor-ssh sensor-telnet sensor-adb sensor-web sensor-docker sensor-redis sensor-mysql sensor-mssql sensor-rdp sensor-smb sensor-sip sensor-synology

    Leave out any sensor you do not run. Events published while Redis restarts are dropped and counted, never buffered without limit.

Ingest and API database passwords (POSTGRES_INGEST_PASSWORD, POSTGRES_API_PASSWORD):

  1. Edit the values in .env.

  2. Run the migration step. It sets both role passwords on every run, from the environment, as SCRAM verifiers, so the clear text never reaches the database logs.

    Terminal window
    docker compose run --rm migrate
  3. Recreate the services that use the roles:

    Terminal window
    docker compose up -d --force-recreate ingest api

Database owner password (POSTGRES_PASSWORD). This one is different: the database container only reads the variable when it first creates the data directory, so editing .env does not change the password inside the database. Change it in the database first, then in .env:

  1. Open a psql shell as the owner and set the new password. \password hashes it on the client, so it is not written to the logs or to the shell history.

    Terminal window
    just psql
    suckerpot=# \password suckerpot

    Use the owner name from POSTGRES_USER if you changed it.

  2. Put the same value in .env as POSTGRES_PASSWORD.

  3. Check that the migration step still works: docker compose run --rm migrate.

If you edit .env first, migrate cannot log in and the stack will not start. Fix it by running \password with the old value in place.

The dashboard has one admin account. Set it up before the first start:

  1. Make the password hash. It prompts twice without echo:

    Terminal window
    docker compose run --rm api python -m api.hashpw
  2. Put the result in .env in single quotes, because it contains $: ADMIN_PASSWORD_HASH='$argon2id$...'. Set ADMIN_USERNAME if you do not want admin.

  3. Set SESSION_SECRET to at least 32 random bytes: openssl rand -base64 48.

  4. Start the stack. just up (and docker compose up) refuse to start at all while ADMIN_PASSWORD_HASH or SESSION_SECRET is missing from .env, and the API refuses a weak hash or secret.

To change the password, make a new hash and put it in .env, then run docker compose up -d api. Changing the hash signs every session out. Rotating SESSION_SECRET does the same, and so does every restart of the API: the signing key includes a random value created at each start, so you sign in again after each restart. The login is limited to 5 attempts per address and 30 in total per minute, and only one password check runs at a time. The API must run as a single worker (the default).

Set API_ALLOWED_HOSTS (or API_ALLOWED_ORIGINS) to the host name you publish. With both empty the API accepts any Host header and logs a warning.

API_AUTH_DISABLED=true turns authentication off, but only together with SUCKERPOT_DEMO=1, and only the test overlay docker-compose.ci.yml (just up-test) sets both. The normal docker-compose.yml does not. Never run the test overlay on a host that other people can reach.

See API for the cookie and CSRF details.

scripts/check-ports.sh (just ports) refuses a port layout that would expose the wrong thing. It runs automatically before just up and just config, and in CI. It fails when:

  • DASHBOARD_PORT is 22, 23, 80, 443 or 3389;
  • two of DASHBOARD_PORT and the *_HOST_PORT variables are equal;
  • one of them equals ADMIN_SSH_PORT, the optional variable in .env that holds the port of the host’s own admin SSH daemon;
  • a value is not a number between 1 and 65535.

The dashboard port must never be a sensor port. The script refuses the five classic service ports listed above. It also compares the dashboard port with every *_HOST_PORT, defaults included, and fails on a clash. Keep the dashboard on a port that no sensor uses. The default, 8443, is safe.

Set ADMIN_SSH_PORT in .env. It is used only by this script. If you start the stack with plain docker compose up, run just ports yourself first.

Docker’s network settings remove the containers’ route to the internet, but they do not stop a compromised container from opening connections to the host itself (for example its admin SSH port) or to other machines on your LAN. The nftables table in scripts/host/firewall.nft closes that gap. The same text is in scripts/host/README.md.

firewall.nft is a template with no address from any real network. It matches Docker networks by interface name, not by address, so it does not depend on the address pools Docker picks.

RuleEffect
input, from sp-ingress and sp-dashboardContainers on the sensor network and the dashboard network cannot open new connections to the host.
input, from docker0, br-* and sp-*, to protected_portsNo container on any Docker network can reach the host’s admin SSH port or the dashboard port.
forward, from sp-ingress and sp-dashboardThese containers cannot start new connections through the host: no LAN scanning, no outbound traffic, no traffic between sensors. Replies to inbound connections still go out.
input, from sp-egress, sp-samples and sp-vpnThe proxy network, the sample network and the vpn container cannot open new connections to the host. Without this, a request through the proxy to the bridge gateway and a published port would reach the host.
forward, from sp-vpnThe only new connection allowed is UDP to the WireGuard endpoint (VPN_ENDPOINT_IP, VPN_ENDPOINT_PORT). Everything else is dropped, so the kill switch holds even if the container’s own firewall fails.

Nothing here affects traffic from the internet to the published sensor ports. Loopback and established connections are always accepted. Docker’s own rules are left alone: the table only adds drops, at a priority ahead of Docker’s chains.

  1. Copy the template to its permanent place, and edit the installed copy, never the tracked file. Your admin port must not end up in git.
  2. Set ADMIN_SSH_PORT to the port of your host’s admin SSH daemon. The file ships with CHANGE_ME on purpose and will not load until you change it.
  3. Check that DASHBOARD_PORT matches your .env. If you use the egress profile, also set VPN_ENDPOINT_IP and VPN_ENDPOINT_PORT to the values of VPN_WG_ENDPOINT_IP and VPN_WG_ENDPOINT_PORT. The file will not load until you do. Without the profile, any valid values such as 203.0.113.10 and 51820 will do, and the rules match nothing.
  4. Keep a second way into the machine (a hypervisor console) the first time.

Install it with the dedicated systemd unit

Section titled “Install it with the dedicated systemd unit”
Terminal window
sudo install -m 0600 scripts/host/firewall.nft /etc/suckerpot-firewall.nft
sudo editor /etc/suckerpot-firewall.nft # set ADMIN_SSH_PORT (and DASHBOARD_PORT)
sudo nft -c -f /etc/suckerpot-firewall.nft # syntax check, changes nothing
sudo install -m 0644 scripts/host/suckerpot-firewall.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now suckerpot-firewall.service

The unit loads only the suckerpot_host table, after docker.service has started. It refuses to start while the file still contains CHANGE_ME, and it checks the syntax before it loads anything. Loading is idempotent: the file deletes and re-creates its own table.

  1. After starting the stack, list the table. Counters on the drop rules stay at 0 in normal operation.

    Terminal window
    sudo nft list table inet suckerpot_host

    An error means the firewall is not active. Check this after every reboot.

  2. From inside a sensor, a new connection to the host must fail. Replace the placeholders: the gateway is the .1 address of the sp-ingress subnet.

    Terminal window
    docker run --rm --network container:<sensor-container> busybox nc -w2 -zv <gateway> <admin-ssh-port>

    It must time out, and the input drop counter must go up.

  3. From the internet, or from another machine, the sensor ports must still answer.

  4. The host listens on each sensor port for IPv4 only. ss -tlnp | grep ':22 ' shows 0.0.0.0:22 and no [::]:22.

Terminal window
sudo systemctl disable --now suckerpot-firewall.service

or, for the running session only, sudo nft delete table inet suckerpot_host. To remove it for good:

Terminal window
sudo rm /etc/systemd/system/suckerpot-firewall.service /etc/suckerpot-firewall.nft
sudo systemctl daemon-reload

Without the table, a compromised sensor can reach the host’s admin SSH port. Do not forward ports while it is off.

The stack’s own outbound traffic (GeoIP downloads, reputation and intel lookups, CVE feeds, alerts, the LLM and the payload collector) leaves through a WireGuard tunnel in the vpn service, never from your own address. Sensors and the dashboard keep their current paths, and the host itself is unchanged. Without the egress profile these features stay idle. Settings shows “no egress”.

You need a WireGuard configuration from any provider that gives you one: an interface private key, interface addresses, the server’s public key, and the server address and UDP port.

Copy the values into .env (never into a tracked file). Split the server address in two:

Terminal window
COMPOSE_PROFILES=egress
VPN_WG_PRIVATE_KEY=<interface private key>
VPN_WG_PEER_PUBLIC_KEY=<server public key>
VPN_WG_ADDRESSES=<interface address, a /32 from the provider>
VPN_WG_DNS=<provider DNS address>
VPN_WG_ENDPOINT_IP=<server IPv4 address, e.g. 203.0.113.10>
VPN_WG_ENDPOINT_PORT=<server UDP port, e.g. 51820>
EGRESS_PROXY_PASSWORD=<at least 24 random characters>
POSTGRES_COLLECTOR_PASSWORD=<at least 24 random characters>
POSTGRES_ANALYZER_PASSWORD=<at least 24 random characters>

Generate the three passwords like the others (openssl rand -hex 24), all different. just up and just config run scripts/check-vpn-env.sh, which names any missing or invalid variable and never prints a value.

The exit address of the tunnel must be inside the expected network. By default that is the /24 of the server address. If your provider exits elsewhere, the gate stays closed with ip_not_allowed. Read the observed exit address in Settings (one click reveals it) and set EGRESS_ALLOWED_CIDRS.

The kill switch has two layers. The second one is on the host. Set VPN_ENDPOINT_IP and VPN_ENDPOINT_PORT in your installed copy of the table (see Host firewall), then load it:

Terminal window
sudo nft -c -f /etc/suckerpot-firewall.nft # syntax check, changes nothing
sudo systemctl restart suckerpot-firewall.service
sudo nft list table inet suckerpot_host # look for the sp-vpn, sp-egress and sp-samples rules
Terminal window
just up # waits for healthy; vpn, collector and analyzer start with the profile
just guardrails # static test of the compose layout (no stack needed)
just egress-check # live proof, see below

just egress-check prints PASS or FAIL per line and exits non-zero on any FAIL. It checks that ingest, collector and analyzer cannot reach a public address directly or resolve external names, that the gate is open, that the proxy refuses the bridge gateway and loopback names, and then takes the tunnel down to prove that nothing leaves. It briefly stops vpn, so run it when a pause of outbound lookups does not matter. For read-only checks that leave the tunnel alone, run scripts/egress-check.sh --quick. It never prints the exit address.

After it, sudo nft list table inet suckerpot_host should show the sp-vpn accept rule counting the handshake packets and the drop counters at 0.

Set COLLECTOR_ENABLED=true and restart. The collector then fetches captured download URLs through the tunnel into the quarantine volume, and the analyzer examines them. Files are never executed. Settings has an Egress card with the gate state, the collector quota and the quarantine use. See Security for the guarantees.

Nothing leaves. Lookups wait with a reason in Settings, events are still stored, and ingest stays healthy. The tunnel restarts itself. Check docker compose logs vpn.

The stack prepares abuse reports and CERT exports. By default it sends nothing: you read each report and send it yourself, from your own anonymous mailbox, behind your VPN (the To send queue helps). Optional SMTP sending can be set up in Settings > Reporting > Sending; it is off by default and only ever leaves through the VPN. It needs the anonymous egress (RDAP lookups go through the tunnel).

Set ABUSE_REPORTS_ENABLED=true in .env and restart ingest. Tuning variables are in Configuration. Reports stay locked (no_self_address) until the stack knows which address it is guarding.

Add a reply contact (an alias that is not on one of your own domains): it is the only required setting. Until it is set, rendering answers settings_incomplete, and a report cannot be marked reviewed. The introduction, closing text, note, signature, subject template and X-ARF user agent are optional. The project ships no wording, because shipped text lives in a public repo and could be searched. Leave them empty to send only the facts: an empty part is left out, an empty subject becomes Abuse report for <address> and an empty X-ARF user agent becomes Mozilla/5.0. If you write your own, make it yours.

The worker learns the address and names of the monitored system from traffic (an address is stored only when two or more distinct /24 or /48 networks used it, and counts only while seen in the last 72 hours). Learned terms start inactive. Open the “Never in a report” list in Settings > Reporting, check each one and confirm it. If your address just changed, add it by hand: manual terms are active at once.

Terminal window
just abuse-guard-check

It renders every open report in every format and the last 7 days of STIX and MISP, and prints PASS or FAIL per format with reason codes only. A non-zero exit means something would leak, or no address is confirmed yet. Run it after changing settings or self terms, and before the first real report.

Open a report at Defend > Abuse reports, check the identity card, copy To, Subject and Body (or download report.txt and evidence.csv to attach), send it from your alias, then use “Mark sent”. Sample bundles are uploaded by hand to the repository you choose. You can also let the dashboard send the mail for you, as described in Sending reports.

By default you send each report yourself. The dashboard can also send it through your VPN, once you save an SMTP profile. Both paths need the anonymous egress to be up.

The To send queue. Open Defend > Abuse reports and choose To send. It walks through the ready reports, oldest first, one at a time. For each report it shows the recipient, the subject and the body, with copy buttons. “Open in mail app” opens a mailto: link with the recipient and the subject. The full plain-text body is copied to the clipboard on every click, so a long report is one paste away. “Sent, next” marks the report as sent and opens the next one. Check the sender address in your mail app before you send, and connect that mailbox through your VPN.

The three modes. Pick the mode in Settings > Reporting > Sending:

ModeWhat happens
off (default)Nothing is sent. Use the To send queue.
clickA “Send now” button sends a ready report, after a confirmation.
autoReady reports are sent after a hold time (default 60 minutes, 10 to 1440). While a report waits, the page shows the time it will go, with a Cancel button. At most daily_cap reports are sent per UTC day (default 20, 1 to 200).

Demo reports are never sent. A report that fails the identity guard, or that has no reply contact, is never sent in any mode. “Send a test” sends a short neutral message to the sender address itself, so you can check the profile first.

Setting up SMTP. You need an SMTP server that accepts your login with STARTTLS (usually port 587) or with SMTPS (usually port 465). Enter the server’s DNS name, not an IP address, with the port, the security, the login name, the sender address and the password. Saving the profile asks for your dashboard password again. The SMTP password is stored encrypted. The dashboard never shows it again. The API reference is in Sending abuse reports.

Free Proton has no SMTP. Proton gives SMTP access only with a paid plan. You also need a custom domain on that plan, and an SMTP token from Proton’s settings. The token is the SMTP password. Free Proton addresses have no SMTP access at all, so keep the mode at off and use the To send queue with them. The Proton preset in Settings fills in the server and the port for you.

Mail only leaves through the VPN. The API has no route out and never connects to a mail server. Ingest sends the mail through the VPN proxy, with the certificate always checked. Before each send, ingest rebuilds the complete message, headers included, and runs the identity guard on it again. While the VPN gate is closed, nothing is sent: a send waits, and a test fails after two minutes.

Two optional or recommended host settings protect the host from floods. The repository never applies them for you. Run them yourself, as the host owner.

The kernel keeps a table of open connections. Scans and floods (many short TCP connections, spoofed UDP to SIP) can fill it. When it is full, the kernel drops new connections for every service on the host, including your admin SSH port. The file scripts/host/sysctl-suckerpot.conf raises the table size and shortens the timeouts of half-open and UDP entries. Install it with:

Terminal window
sudo install -m 0644 <repo>/scripts/host/sysctl-suckerpot.conf /etc/sysctl.d/90-suckerpot.conf
sudo sysctl --system
sysctl net.netfilter.nf_conntrack_max # verify

Replace <repo> with the path of your clone, for example the folder where you ran git clone. The sysctl --system command reloads all sysctl files, so it applies the other files in /etc/sysctl.d/ too.

Each container already has its own limits in docker-compose.yml. The optional file scripts/host/suckerpot-sensors.slice caps their sum, so that a flood on every sensor at once cannot starve Redis, the database, ingest or the API. It sets a memory ceiling of 1536 MB, no swap and a CPU quota of 120 %. Nothing uses it by default.

  1. Check the Docker cgroup driver. The slice only works with the systemd driver: docker info | grep -i 'cgroup driver'. If the driver is cgroupfs, skip this section.

  2. Install the slice unit and reload systemd:

    Terminal window
    sudo install -m 0644 <repo>/scripts/host/suckerpot-sensors.slice /etc/systemd/system/
    sudo systemctl daemon-reload
  3. In docker-compose.yml, add cgroup_parent: suckerpot-sensors.slice to each sensor-* service, then recreate the sensors. Only the host owner should make this change.

The firewall template has a commented-out rule that rate-limits new SIP flows on UDP 5060 before they reach the sensor. Read the comment in scripts/host/firewall.nft before you enable it. Keep it off until you have measured your normal SIP traffic.

DASHBOARD_BIND is the host address the dashboard port binds to.

SetupDASHBOARD_BINDFilter
The tunnel runs on the same host127.0.0.1 (default)None needed. The port is reachable from the host only.
The tunnel runs on another machineThe host’s LAN addressFilter the port upstream: accept it only from the tunnel’s address, drop everything else.

A host INPUT rule is not enough, because Docker publishes the port in its own rules. Filter at the router or hypervisor firewall, or in the DOCKER-USER chain. Example rules, with documentation addresses only:

RuleSourcePortAction
1198.51.100.10 (tunnel machine)8443/tcpAccept
2any8443/tcpDrop

Test from a machine that is not the tunnel machine: the dashboard port must not answer.

This matters most if you forward all ports to the host (a DMZ). A DMZ also forwards the dashboard port. Put the upstream filter in place first.

A tunnel such as cloudflared usually terminates TLS and forwards plain HTTP to the dashboard port. The browser then loads the page from https://dash.example.com while the API sees a plain-HTTP request with another Host. The live WebSocket checks the Origin header against the request’s host, so it would be refused (close code 1008), and the dashboard would fall back to polling.

Fix it by listing the public origin in .env:

API_ALLOWED_ORIGINS=https://dash.example.com

Use full origins (scheme and host, no path), comma-separated. Then restart the API: docker compose up -d api. Same-host origins are always accepted, so local use keeps working.

Also set the tunnel’s own access policy (for example Cloudflare Access) in front of the dashboard, as a second layer in front of the dashboard login. If you give a colleague an account (Settings > Users), add their email address to the Cloudflare Access policy as well, or they never reach the sign-in page.

Keep COOKIE_SECURE=true (the default). The browser talks HTTPS to the tunnel, so the __Host- cookies work, even though the tunnel talks plain HTTP to the API. Production must be served over HTTPS. COOKIE_SECURE=false (plain cookie names, no Secure flag) is for local development over HTTP only. Without the full origin in API_ALLOWED_ORIGINS, login fails with 403 and the live feed is refused, so set it before you try to sign in. Also set API_ALLOWED_HOSTS to the tunnel’s host name, or the API accepts any Host.

Set AUTH_TRUSTED_PROXIES to the CIDR range of the machine that runs the tunnel. Then the API takes the client address from the tunnel’s CF-Connecting-IP header, but only for requests that really come from that range. Without it, every login attempt appears to come from the tunnel, and the per-address login limit becomes one limit for everybody.

Every data volume (db-data, redis-data, quarantine, ssh-hostkeys, vpn-config, geoip-data, backups and the sensor data volumes) is declared external: true in docker-compose.yml, named ${SUCKERPOT_VOLUME_PREFIX}_<volume> (live: suckerpot_<volume>). SUCKERPOT_VOLUME_PREFIX is a required variable from .env (.env.example ships it commented out: uncomment it as SUCKERPOT_VOLUME_PREFIX=suckerpot in the live .env only): a checkout without .env cannot run any compose command, so it can never mount the live data. Compose fills in COMPOSE_PROJECT_NAME from the folder name on its own, which is why a separate variable is needed. When you upgrade an existing install, add SUCKERPOT_VOLUME_PREFIX=suckerpot to .env first. Compose never creates, owns or deletes an external volume, so docker compose down -v, a different project name or a checkout in another folder cannot wipe the live data. (A second stack that mounts the same volumes is still a bad idea: never run two databases on one data directory.)

just up runs scripts/ensure-volumes.sh first. It creates the missing volumes (docker volume create is idempotent) and never touches the data of an existing one. Plain docker compose up on a host where they do not exist fails with “external volume not found” instead of starting empty.

The test overlays (docker-compose.it.yml for just test-integration, docker-compose.ci.yml for CI and just up-test) turn every volume back into a project-local one with an explicit name test-<prefix>_<volume>, which can never equal a live <prefix>_<volume> name, even when a test is given the live project name. The live recipes also refuse to run from a linked git worktree, and just restore checks the dump with pg_restore -l before it drops anything. Guardrail tests (just guardrails, in CI) assert that no service of either overlay mounts an external or suckerpot_* volume, and that the base file’s data volumes are external. just test-integration also refuses to run unless its project name contains -it-, and just up-test never uses the live project name. Never copy .env.example to make a test .env: its project name and volume prefix are the live ones. The live recipes (up, down, ps, logs, backup*, restore, psql, demo, egress-check) refuse to run unless both variables are set, and just test-integration, just up-test and the CI job set their own throwaway values.

If suckerpot_db-data is missing but suckerpot_backups holds dumps, just up stops with a message instead of silently creating an empty database. Then either restore (create the volumes with scripts/ensure-volumes.sh --force-empty, start only the database with docker compose up -d --wait db, then just restore latest), or start from scratch on purpose with just up --force-empty (or SUCKERPOT_ALLOW_EMPTY_DB=1).

The backup service (backend network only, no route out, non-root, read-only) runs pg_dump -Fc (TimescaleDB data included) with the database owner account, once a day, into the suckerpot_backups volume. It also dumps at start when the newest dump is older than 24 hours, keeps the newest 7 (BACKUP_KEEP) plus the newest dump of each of the last 4 weeks and 3 months. It needs 2 GiB plus twice the last dump free (it prunes to 6 daily dumps first when tight, and fails if that is not enough). A dump smaller than half of the previous one is kept but nothing is pruned and the service turns unhealthy (marker file /backups/.shrunk: look at the database, then remove it). The healthcheck also fails when no dump younger than 26 hours exists, so docker compose ps shows a failing backup. One backup runs at a time (just backup and the daily loop share a lock). The test overlays keep this service behind the profile backup, so just up-test and CI never start it.

Terminal window
just backup # dump now into the backups volume
just backups # list the dumps
just backup-host # dump into the host folder ./backups (BACKUP_DIR, BACKUP_KEEP), for copies off the machine

The quarantine volume is not backed up: it holds captured malware that can be fetched again.

A volume lives on the same disk as the data it protects. For a real safeguard, copy dumps off the machine to storage you control (for example just backup-host, then copy ./backups), and keep Proxmox snapshots too.

To restore:

Terminal window
just restore latest # newest dump of the backups volume
just restore volume:suckerpot-20261101T020000Z.dump
just restore backups/suckerpot-20261024T020000Z.dump # a dump file on the host

This replaces the whole database. The script asks you to type restore, stops ingest and the API, recreates the database, restores the dump, runs the migrations (which re-apply the role grants) and starts ingest and the API again. Sensors keep running during a restore: their events wait in Redis and are ingested afterwards.

Nothing in the stack deletes a live volume. To wipe one deliberately, stop the stack (just down), then remove the volume by name, for example docker volume rm suckerpot_db-data. Do it only after just backup-host and a check that the copy is readable. Never run docker volume prune or docker system prune --volumes on this host.

Take a snapshot of the VM, or a full image of the machine, in a clean state after the stack works and before the first exposure. If the host is compromised, you can restore a clean state in minutes. Name it clearly, for example clean-before-internet. Update it when you change the setup.

Forward the sensor ports to the host, then verify each one. Forward only the ports of sensors that are actually running. Each row is one host port. The variable is the one in .env that sets it.

SensorPortProtocolVariable
SSH22TCPSSH_HOST_PORT
Telnet23TCPTELNET_HOST_PORT
ADB5555TCPADB_HOST_PORT
Web80 and 443TCPHTTP_HOST_PORT and HTTPS_HOST_PORT
Docker API2375TCPDOCKER_HOST_PORT
Redis6379TCPREDIS_HOST_PORT
MySQL3306TCPMYSQL_HOST_PORT
MSSQL1433TCPMSSQL_HOST_PORT
RDP3389TCPRDP_HOST_PORT
SMB445TCPSMB_HOST_PORT
SIP5060UDP and TCPSIP_HOST_PORT
Synology DSM5000 and 5001TCPSYNOLOGY_HTTP_HOST_PORT and SYNOLOGY_HTTPS_HOST_PORT

The dashboard port (DASHBOARD_PORT, 8443 by default) and the admin SSH port are never forwarded. Keep them off the list.

The host must not run its own service on any of these ports. For example, a Samba server on 445 or a MySQL server on 3306 would collide with the sensor.

The SIP sensor answers each UDP datagram to the address that sent it. Its per-source limits and its verification of the source depend on that address being the attacker’s. Docker can break this. For a published port, Docker uses NAT (iptables), which keeps the source address. But when the userland proxy (docker-proxy) handles the port, the sensor sees the proxy’s address for every datagram. Then every attacker looks like one source.

The userland proxy is on by default and applies to all published ports. To keep the source addresses for SIP, the host owner must turn it off:

  1. Add "userland-proxy": false to /etc/docker/daemon.json. Keep any other keys in the file.
  2. Restart the Docker daemon. This stops the containers unless live-restore is set, so plan a short window.
  3. Test from a machine outside your network. Send one SIP OPTIONS request to UDP 5060 and check the connect event in the dashboard. Its source address must be the address of the test machine. It must never be a private address of the Docker bridge.

With the proxy off, a connection from the host to its own published ports may stop working. The internet path is the one that matters, and it uses NAT.

From a machine outside your network:

  • The sensor ports answer (a connection to port 22 gets an OpenSSH banner, and a connection to 6379 gets a Redis reply).
  • UDP 5060 answers an OPTIONS request, and the SIP connect event shows the real source address.
  • The dashboard port does not answer.
  • The admin SSH port does not answer.
  • The tunnel URL reaches the dashboard, and the Live indicator reads “Live” without the “Polling” note.

Then check the logs for new events:

Terminal window
docker compose logs --tail=50 ingest

Use just update. It takes a database dump first, checks the image signatures and records how to go back. just rollback puts the previous state back. See Updates and rollback.

Migrations run on start. Read the changelog first for anything that needs a change in .env.

SymptomLikely cause and fix
After an upgrade the browser shows only the dark background.The browser kept old app files. Close every tab of the dashboard, then hard-reload with Ctrl+Shift+R.
The dashboard is very slow through a tunnel, but the server is idle.Usually the uplink is saturated. Compare a ping to your gateway with a ping to an internet host (for example ping -c 10 192.0.2.1 for the gateway and ping -c 10 example.com). High loss or delay on both points at the uplink, not at SuckerPot.
The first image build fails with a DNS error.A transient name-resolution failure while pulling a base image or package. Run docker compose build again.
  • Watch the disk usage. Attackers can send a lot of data. Check it every day for the first week.
  • Check docker compose ps for restarting sensors. A sensor that restarts often points at an attack that hits a limit.
  • Keep the host updated. Apply security updates regularly.
  • Keep the snapshot up to date when you change the setup.
  • Read the logs of the admin SSH service, not only of SuckerPot.
  • API rate limits beyond the login limits (M8).