Getting started

Install on a Linux host

The supported deployment of self-hosted ShakerScan Enterprise, Docker Compose on one Linux host you operate, from requirements through install, start at boot, firewall, backup and restore, and upgrade.

SectionGetting started
UpdatedNovember 9, 2026
EditionSelf-hosted Enterprise, public beta

The supported beta deployment is Docker Compose on one Linux host you operate, whether a cloud VM, a VPS or an on-premises virtual machine, on x86_64 (the images are linux/amd64; there is no ARM build). This guide was executed end to end on clean Ubuntu 24.04 hosts with the one-command installer, including a reboot and a backup; Ubuntu 26.04 is validated in continuous operation. Other distributions are not validated yet. Kubernetes and ECS layouts exist as evaluations and are not the supported target.

1. Requirements

ItemMinimumRecommended
Operating systemUbuntu 24.04 LTS or Ubuntu 26.04, x86_64 (the images are linux/amd64; there is no ARM build), cgroup v2. Other distributions are not supported yetUbuntu 24.04 LTS
DockerDocker Engine 24+ with the Compose plugin 2.20+ (docker compose)Docker 27+ / Compose 2.30+
CPU, memory and diskWhat the installer refuses: Host sizeHost size
Network inTCP 443 from your users; TCP 80 and 443 from the internet if you use ACME certificatesRestrict 443 to corporate ranges; ACME still needs 80 or 443 reachable by the CA
Network outDocker Hub (engine images), the vendor registry (gateway image), GitHub (the engine installer and attestation checks) and the Sigstore services (signature checks) at install and update time; GitHub (the public engine release catalog) and shakerscan.com (the signed gateway release channel), read by the gateway only when an administrator's console shows or requests updates; your identity provider if SSO is configured; your Jira site if an administrator connects it; the targets you are authorized to scan; the ACME CA; the public certificate-transparency logs while the CT monitor is onThe same; nothing about your installation is sent anywhere at run time
HostnameA DNS name pointing at the host. Caddy obtains and renews a Let's Encrypt certificate automaticallyA corporate hostname; use your own certificate through Caddy if ACME is not allowed
Operator accountA non-root user in the docker groupThe same
Verification toolscosign and the GitHub CLI (gh), used by the installer to check the image signature and build attestationsThe same
From the vendorThe Enterprise image reference (a digest) and a pull token, the delivery kit it carries, and a license for your installation id
From the public releaseThe installed engine runtime directory produced by the public installer (step 2 below)

Host size

The installer checks memory and free disk before it changes anything. This table is what it checks and what it then runs. Memory is the total /proc/meminfo reports, in whole GiB, as the installer reads it: a machine sold as 8 GB reports about 7.7 GiB, which counts as 7, and a 16 GB machine counts as 15. Disk is the free space where the deployment goes (/srv by default), in whole GiB.

Installer refusesInstaller warnsRecommended
Memory (GiB)below 77 to 14: enough for one concurrent scan15 or more (a 16 GB machine) for one scan worker, and 4 more for each further worker: 19 runs 2, 23 runs 3, 31 runs 5, up to 16 workers; with network scans on, 2 more (how workers are sized)
Free disk (GiB)below 2040 GB SSD; 100 GB or more, as evidence grows with retained scans
CPUnot checked4 vCPU for one scan worker, 8 vCPU for several concurrent scans

A host the installer accepts below 15 GiB runs one scan worker, whatever the licence allows.

2. Install

Run these as root on the host, with the image reference, pull token and licence file from your delivery note. The installer checks the host first and lists everything missing before it changes anything.

# 1. Sign in to the registry with your pull token and extract the delivery kit from the image.
echo "$PULL_TOKEN" | docker login ghcr.io -u <pull user> --password-stdin
IMAGE=ghcr.io/andriyze/shakerscan-enterprise@sha256:<digest>
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/out" "$IMAGE" python -m shakerscan_saas.enterprise.cli kit --output /out/shakerscan-kit

# 2. Install. GH_TOKEN lets the attestation checks run without GitHub's anonymous rate limit.
GH_TOKEN="$PULL_TOKEN" shakerscan-kit/self-hosted/install.sh --image "$IMAGE" \
    --public-url https://scanner.example.com --installation-id acme-prod \
    --organization 'Acme Security' --license acme-prod-license.jwt --admin admin@example.com

In about five minutes on a reference host it: verifies the host (Docker and Compose versions, memory, disk, ports 80 and 443, DNS for your hostname, registry access, and your licence's signature, installation id and validity); installs the engine runtime at the newest release this gateway has reviewed, from the installer at the exact commit its certification receipt names, and checks the installed image lock against the published one; verifies the gateway image's signature and SBOM and every engine image's attestation; renders the deployment under /srv/shakerscan-enterprise; installs your licence; starts the stack and waits for it to be healthy; creates the first administrator; and installs three systemd units (the stack at boot, a daily backup, and the engine updater behind the console's update button). It finishes by checking that the gateway is using your licence and prints where everything is:

Sign in:           https://scanner.example.com/_enterprise/login  as admin@example.com
Initial password:  /etc/shakerscan/initial-admin-password
Engine:            2.12.1 (update it later from the console's Deployment card)
Backups:           daily to /var/backups/shakerscan, passphrase in /etc/shakerscan/backup-passphrase

Then open the sign-in page and sign in with the initial password. Before anything else the console asks you to choose your own password (the generated one is only for the first sign-in) and to enroll an authenticator app or a passkey (required for administrators); then delete /etc/shakerscan/initial-admin-password and configure single sign-on (Okta guide). Copy the backup passphrase and the backups off the host.

Options, all optional: --engine-version, --worker-replicas and --max-concurrent-scans (set the scan workers and the engine's cap yourself instead of the sizing described under scan workers and licensed slots; the cap at most your licensed slots), --private-network-targets refuse (private addresses are allowed by default, see what scans may reach), --nat64-prefixes (your network's own NAT64 prefixes, engine 2.8 or later), --trusted-front-proxies (your own proxy or tunnel in front of the host, see Behind your own proxy or tunnel), --tls-certificate and --tls-key for a certificate from your own CA (people's laptops then need to trust that CA: the client reads SSL_CERT_FILE, see Work with an agent), --outbound-proxy, --hunt off (Hunt is on for operators by default), --no-attack-surface (the Exposure views are on by default), --admin-password-file, and the directories (--engine-dir, --deployment, --kit-dir, --backup-dir). --preflight-only runs the checks and stops. Running the installer again resumes an interrupted install; it never touches a deployment it did not create. A re-run does not render the deployment again, so a sizing, network or certificate option given then is not applied; the installer names any that differs from the deployment's and how to change it (the scan workers and the cap: scan workers and licensed slots). Every new deployment gets a new initial password. A file an earlier install left at that path is never reused: the installer moves it aside (initial-admin-password.earlier-…, which it names) before it creates the administrator. A re-run keeps the one it generated, and writes none once an administrator exists.

<details><summary>What the installer does, step by step (manual install)</summary>
docker pull "$IMAGE"
shakerscan-kit/self-hosted/verify.sh "$IMAGE" /srv/shakerscan-public-release
SHAKERSCAN_HOME=/srv/shakerscan-public-release SHAKERSCAN_START=0 sh -c 'curl -fsSL https://install.shakerscan.com | sh'
chmod -R a+rX /srv/shakerscan-public-release && chmod 600 /srv/shakerscan-public-release/.env
shakerscan-kit/self-hosted/prepare.sh "$IMAGE" /srv/shakerscan-public-release \
    /srv/shakerscan-enterprise https://scanner.example.com acme-prod 'Acme Security'
cp license.jwt /srv/shakerscan-enterprise/secrets/license.jwt
sed -i 's|^SHAKERSCAN_ENTERPRISE_LICENSE_MODE=.*|SHAKERSCAN_ENTERPRISE_LICENSE_MODE=signed|' /srv/shakerscan-enterprise/enterprise.env
echo 'SHAKERSCAN_ENTERPRISE_LICENSE_FILE=/run/enterprise-secrets/license.jwt' >> /srv/shakerscan-enterprise/enterprise.env
chown 10001:10001 /srv/shakerscan-enterprise/secrets/* && chmod 0440 /srv/shakerscan-enterprise/secrets/*
cd /srv/shakerscan-enterprise && docker compose -f compose.json up -d --wait
docker compose -f compose.json exec enterprise python -m shakerscan_saas.enterprise.cli bootstrap --username admin@example.com
docker compose -f compose.json exec enterprise python -m shakerscan_saas.enterprise.cli check

plus the systemd units in self-hosted/systemd/ with your paths. Set LICENSED_SLOTS (your licence's concurrent scans) for prepare.sh to size the scan workers as the installer does.

</details>

Scan workers and licensed slots

Each scan worker runs one scan or one finding retest at a time, so the licensed slots are usable only with as many workers. The installer reads your licence's slots and runs one worker per slot, as far as the host's memory holds them (Host size): a 16 GB host, and any smaller one the installer accepts, runs one worker (each is limited to 4 GiB), and every further 4 GiB of memory runs one more (2 GiB less with network scans on). It pins the engine's concurrent-scan cap to the same number and prints it ("scan workers: 2"). upgrade.sh, and the console's engine and gateway updates that run it, size the workers again from the installed licence and the host's memory, so a new licence or more memory takes effect at the next upgrade; memory or a licence it cannot read leaves the workers as they are. A deployment installed before this sizing existed is sized at its next upgrade unless you chose --worker-replicas or --max-concurrent-scans, which are always kept as given.

To set the workers or the engine's cap yourself on an installed deployment (from 0.2.0-beta.47), re-render it with the gateway image it runs and restart:

IMAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["services"]["enterprise"]["image"])' /srv/shakerscan-enterprise/compose.json)"
WORKER_REPLICAS=5 MAX_CONCURRENT_SCANS=5 /opt/shakerscan/kit/self-hosted/upgrade.sh "$IMAGE" \
    /srv/shakerscan-public-release /srv/shakerscan-enterprise
docker compose -f /srv/shakerscan-enterprise/compose.json up -d --remove-orphans

The values are checked as at install (whole numbers, 1 to 16 workers and a cap of 1 to 200) and recorded in deployment.json. From then on they are your choice, like the installer's --worker-replicas and --max-concurrent-scans: later upgrades and the console's updates keep them instead of sizing the workers to the licence. Either can be given alone. MAX_CONCURRENT_SCANS alone keeps the workers you have. WORKER_REPLICAS alone keeps a cap you chose; a cap the sizing set follows the new number of workers. Keep the cap at most your licensed slots. The re-render only writes files; up then recreates what changed (the engine API for a new cap, and a lower count stops the extra workers, so a scan running on one ends failed), so run it when no scan is running.

More workers than the sizing gives need memory to spare. On an 8 GB test host (2026-10-25), five idle workers and the rest of the stack used 2.0 GB of 7.7 GB, about 125 MiB per idle worker; a worker running a scan may use up to its 4 GiB limit, which is what the sizing above budgets for. Memory with several scans running at once has not been measured yet. The console's health card watches it for you: its Memory item shows the host's available memory and the processes the kernel killed for lack of memory (OOM kills) in the last day, or since the gateway started when that is shorter. It turns yellow below 10% available or after any OOM kill, and red below 5%; then lower WORKER_REPLICAS or give the host more memory.

With fewer workers than licensed slots (a small host, or your own choice), the gateway still admits up to the licensed slots: a scan admitted beyond the workers waits in the engine's queue ("Waiting for a worker") and holds its licensed slot while it waits, and a retest waits behind the scans that are running. The console's License card shows this as "engine cap" below the licensed slots, with what it means.

A retest holds its licensed slot while the engine's retest record is queued or running and gives it back when the retest completes, fails or is cancelled. A retest whose answer the gateway never received (the engine did not reply in time) keeps its slot until the engine shows no retest queued or running, then gives it back; an administrator can also release it from the License card.

3. Start at boot

The installer installs and enables this unit (and the backup and updater timers) for you; it is shown here for a manual install.

# /etc/systemd/system/shakerscan-enterprise.service
[Unit]
Description=ShakerScan Enterprise (Docker Compose)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
# Not while a restore is unfinished (see Backup and restore)
ConditionPathExists=!/srv/shakerscan-enterprise/restore-in-progress

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/shakerscan-enterprise
# While an engine update's rollback is unfinished, only the datastores, gateway and proxy start
# (the previous release must not start on the database the new one migrated)
ExecStart=/bin/sh -c '\
  if [ -e /srv/shakerscan-enterprise/engine-rollback-incomplete ]; then \
    echo "/srv/shakerscan-enterprise/engine-rollback-incomplete: an engine update rollback is unfinished; starting only postgres, redis, enterprise and https" >&2; \
    cat /srv/shakerscan-enterprise/engine-rollback-incomplete >&2; \
    exec /usr/bin/docker compose -f /srv/shakerscan-enterprise/compose.json up -d --no-deps postgres redis enterprise https; \
  fi; \
  exec /usr/bin/docker compose -f /srv/shakerscan-enterprise/compose.json up -d --remove-orphans'
ExecStop=/usr/bin/docker compose -f /srv/shakerscan-enterprise/compose.json down
TimeoutStartSec=600

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now shakerscan-enterprise.service

After a reboot the unit is active, all containers come up healthy within about 30 seconds, the certificate is reused from its volume, and identities and scans are intact.

Units installed by a gateway release of 0.2.0-beta.44 or older have no ConditionPathExists line, so they start the stack at boot (and run the backup and the updater) even in the middle of a restore; restore.sh names such units when it runs. Once the kit at /opt/shakerscan/kit is this release's or newer (upgrade.sh refreshes it), reinstall the units from it with the loop in Restore onto another host and systemctl daemon-reload.

4. Firewall

Allow inbound TCP 443 (and 80 for ACME's HTTP challenge and the redirect) and your SSH port; deny everything else. The engine, database and cache publish no ports; only Caddy does. With ufw:

sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw enable

On a cloud VM apply the same rules in the security group. Docker manages its own iptables chains; do not expect ufw alone to block published container ports.

Outbound, the host needs Docker Hub and the vendor registry for image pulls, GitHub (the engine installer and attestation checks) and the Sigstore services (cosign verification) at install and update time, GitHub (the public engine release catalog) and shakerscan.com (the signed gateway release channel, /enterprise/gateway-channel.jwt) when an administrator's console shows or requests updates, Let's Encrypt unless prepare was given your certificate, your identity provider if SSO is configured, your Jira site if an administrator connects it, the public certificate-transparency logs while the CT monitor is on, and the scan targets; the security overview lists the exact destinations and when each is contacted. Nothing is fetched at scan time. prepare --outbound-proxy http://proxy:3128 routes the ACME client, the gateway's identity-provider calls and its gateway release channel read through a corporate proxy (and its calls to Jira); the gateway reads the engine release catalog directly, scan traffic to targets is never proxied, and image pulls use the Docker daemon's own proxy settings.

Behind your own proxy or tunnel

What the gateway sees as a client's address behind a load balancer, reverse proxy or tunnel of your own depends on the kind of front end. The address matters for the per-address limits on sign-in attempts and connect links, and it is the audit log's client address (each record's address, in the console as the row's tooltip and in the export as address).

A pass-through load balancer forwards the encrypted connection to Caddy without reading it (TCP or TLS pass-through: an AWS Network Load Balancer, HAProxy in mode tcp, nginx stream, a cloud TCP load balancer). This is the natural setup here, because Caddy terminates TLS with its own certificate. Such a balancer cannot add an X-Forwarded-For header, so configure it to keep each client's source address (client IP preservation, or forwarding without source NAT): Caddy then sees every client's own address (over IPv4; IPv6: see the last point below), and no setting is needed. If it rewrites source addresses instead, every client appears as the balancer's address and shares one sign-in and connect-link budget, and the audit log names the balancer; the deployment does not support the PROXY protocol, which would carry the address. Never list a pass-through balancer as a trusted front proxy: it passes on whatever X-Forwarded-For a client sends, so every client could choose its own address.

An HTTP(S) proxy or tunnel that terminates TLS and appends the address it received each connection from to X-Forwarded-For (cloudflared and Cloudflare's proxy do; nginx with proxy_add_x_forwarded_for and an AWS Application Load Balancer can) makes every connection reach Caddy from that proxy. List the addresses it connects from, at install with --trusted-front-proxies 10.0.4.0/24,10.0.5.7, or on an existing deployment:

TRUSTED_FRONT_PROXIES=10.0.4.0/24,10.0.5.7 /opt/shakerscan/kit/self-hosted/upgrade.sh "$IMAGE" \
    /srv/shakerscan-public-release /srv/shakerscan-enterprise
docker compose -f /srv/shakerscan-enterprise/compose.json restart https

(TRUSTED_FRONT_PROXIES=none removes them; without the variable an upgrade keeps them.) Caddy then believes X-Forwarded-For only on connections from those addresses, and takes the rightmost address in it that is not one of them. That is the client's address only because each listed proxy appends the address it received the connection from: what a client writes in the header itself ends up to the left of it and is ignored. The setting is safe only while that holds for every address you list. People still use the public URL: sign-in requires the browser's origin to be the public URL and passkeys are bound to its name, so the proxy or tunnel has to serve that same name. Caddy still needs a certificate for that name: either the proxy lets Let's Encrypt's challenge on port 80 through to Caddy, or you give the installer a certificate that the proxy trusts (--tls-certificate and --tls-key).

List only addresses that nothing but such a proxy connects from. Whatever else connects from a listed address can name any client address: it chooses its own address in the audit log and its own per-address budget for sign-in attempts and connect links, or uses up someone else's (the per-account and overall sign-in limits still apply). A range wider than /8 (IPv4 or IPv6; list fd00::/8 rather than fc00::/7) is refused, and at most 20 ranges are accepted.

  • A proxy on another machine, at fixed addresses: list exactly those. Over IPv4 its connections reach Caddy with their own address. To make every client come through it, allow 80 and 443 on this host only from it; Docker's published ports bypass ufw, so use the DOCKER-USER chain (and ip6tables for IPv6).
  • A tunnel connector (cloudflared, for example): run it on another machine or a small VM with a fixed address, point it at this host, and list that address.
  • Not the address a proxy on this host itself connects from. A connector started on the host and pointed at https://127.0.0.1 reaches Caddy from the Docker network's gateway address (the address the audit log shows for its requests). So does every connection Docker relays instead of routing: every process on the host that connects to the published ports, and every IPv6 client of them (the deployment's networks are IPv4-only, so Docker relays IPv6 connections from that same address). Listing it lets all of them name any client, and the address changes when the network is recreated. A container on another Docker network that connects to the published ports arrives from it too. Only on a host where nobody else runs anything, with IPv6 to ports 80 and 443 blocked (ip6tables) or no IPv6 address at all, can you list it; check it after every docker compose down and up with docker network inspect enterprise-<installation id>_edge --format '{{(index .IPAM.Config 0).Gateway}}'.

5. Backup and restore

self-hosted/backup.sh and self-hosted/restore.sh in the kit work for an unprivileged operator: the results directory and the secrets belong to the gateway's user after the first start, so the scripts read and write them through a helper container with no network (alpine:3.20, pinned by digest; the installer loads it, so later backups need no registry access). Keep the backup directory encrypted at rest and off the host.

The installer installs and enables the daily backup: shakerscan-enterprise-backup.timer runs shakerscan-enterprise-backup.service, which takes a timestamped backup under /var/backups/shakerscan with scheduled-backup.sh, keeps the newest 14, writes last-backup.json there and reports the outcome to the console's health card (Backups). Each backup is a full copy, about the size of the engine database plus the results directory (1.4 GB measured on a host with two months of daily scans), so it also removes older backups, never the newest two, while the backup disk has less than a fifth free or less than twice the newest backup; the card shows how many are kept, their size and the backup disk's free space. Copy backups off the host for a longer history. Until the first night's run the card says that no backup has reported yet. A backup that fails while the gateway is stopped (during an update or a restart, for example) cannot report there, so the card also turns red when no backup has reported for 27 hours (a day, the timer's random delay of up to 30 minutes and a long backup); last-backup.json and the journal always have the outcome. To back up now, and have it show on the health card:

sudo systemctl start shakerscan-enterprise-backup.service
journalctl -u shakerscan-enterprise-backup.service -n 20    # where it went, or why it failed

Running backup.sh by hand also writes a complete backup, to a directory you name, but it does not report to the console. On a host installed without the installer, install the same units from the kit's self-hosted/systemd/ with your paths; the service the installer writes is:

# /etc/systemd/system/shakerscan-enterprise-backup.service
[Service]
Type=oneshot
Environment=BACKUP_PASSPHRASE_FILE=/etc/shakerscan/backup-passphrase
ExecStart=/opt/shakerscan/kit/self-hosted/scheduled-backup.sh /srv/shakerscan-enterprise /var/backups/shakerscan 14

A backup also holds the recurring scans and their runs' records, workflow.sqlite3 (gateway 0.2.0-beta.48 and later), taken before the engine database; it is not encrypted, like the database dump (Recurring scans). A workflow file the gateway cannot export is left out, with the reason in the backup's MANIFEST, and the rest of the backup is still taken.

The identity part of a backup (the gateway's state: people, sign-in factors, service tokens and the audit log, with the MFA sealing key) has no size limit. The gateway streams it, encrypted, into the backup directory on the host and stages its snapshot on its own state volume, never in the container's 64 MiB /tmp (gateways up to 0.2.0-beta.45 wrote the file there and refused an identity database over 64 MiB). What a backup needs is free space: about twice the identity database on the disk that holds the Docker volumes, and the backup itself where it is written. The console's health card shows the database's size and how many audit records the last 24 hours added under Identity backup, and says when the state volume is short of the room a backup needs. A failed backup leaves no partial backup directory; it names the step and the reason in the last line of backup.sh's output and on the health card under Backups. A backup.sh that is stopped, even with kill -9, stops the gateway's side of the backup too: it removes its copy from the state volume and records the backup as abandoned in the audit log. Gateway temporary space warns on the card when something was copied into the gateway container's /tmp.

Audit retention keeps the audit log finite. Each period is set in enterprise.env (0 keeps that kind forever), applied with docker compose -f compose.json up -d; the gateway applies it every six hours and cli audit-retention at once. The health card shows the last run under Audit retention. A run that cannot finish (the volume is full, or the gateway cannot write a file of the audit archive, for example one copied back there as root) removes nothing it could not archive, finishes the other kinds, records an audit.retention row with outcome incomplete and the reason, shows the reason and its fix on the card, and is tried again six hours later; cli audit-retention prints the reason and exits 1:

RecordsKept by defaultThen
Reads through the gateway to the engine (engine.read, streams, browser capture uploads)30 days, SHAKERSCAN_ENTERPRISE_AUDIT_PROXY_RETENTION_DAYScounted per day and kind, then removed
Security events: sign-ins, refusals, throttling, session starts and ends, factor and step-up checks, failed invitation, password-reset and connect codes400 days, SHAKERSCAN_ENTERPRISE_AUDIT_SECURITY_RETENTION_DAYS (at least 90 and no shorter than reads; when it is not set and reads are kept forever or longer than 400 days, it follows the reads)written to the audit archive, then removed
Changes made on the engine (engine.request and engine.response)400 days, SHAKERSCAN_ENTERPRISE_AUDIT_CHANGE_RETENTION_DAYS (at least 90)written to the audit archive, then removed
Administrative records: people, roles, settings, tokens, approvals, decisions, updates, licence, backupsalwaysnever removed

400 days is a twelve-month audit period and a month. The audit archive is in the gateway's state volume, in /data/enterprise/audit-archive/: one file per kind and month of the records (security-2025-09.ndjson.gz, change-2025-09.ndjson.gz), each record exactly as the audit export writes it (NDJSON; zcat reads a whole file). Before retention removes a batch of records it writes them there and records an audit.archive row with the file, the place in it, the number of records, their ids and the SHA-256 of the lines; cli audit verify checks every one and exits 1 if one is missing, cut short or altered. Every backup carries the archive (audit-archive.bak, encrypted with the backup passphrase) and a restore puts it back with the identity state; the restore and its check compare it with the audit log and warn when a part the log names is missing or altered (it was so on the backed-up host already; the restored log records it), and when the identity backup's log names archived records but no audit-archive.bak is beside it. The archive is kept on the host, and in every backup, until you remove months of it: copy them off the host first, then cli audit archive-remove --before YYYY-MM removes the files of the months before that one, after recording each file and its SHA-256 in the audit log, and cli audit verify counts their parts as removed from then on (--dry-run lists them first). Retention writes each record into the file of the record's own month, and it is still writing the files of the month "now minus the retention" and later (with 400 days, about thirteen months back), and those of any month whose records are still in the log. The command refuses such a file and names the latest --before it accepts, so a file you copied is not written again under the same name (unless records of that month come back with a restore or a gateway rollback; cli audit verify checks the new file as any other). On 2026-10-24 with the defaults, for example, that is --before 2025-09:

cli() { docker compose -f compose.json exec -T enterprise python -m shakerscan_saas.enterprise.cli "$@"; }
cli audit verify                                   # every archived part is there and unaltered
docker compose -f compose.json exec -T enterprise ls -l /data/enterprise/audit-archive
docker compose -f compose.json exec -T enterprise cat /data/enterprise/audit-archive/security-2025-08.ndjson.gz > security-2025-08.ndjson.gz
cli audit archive-remove --before 2025-09 --dry-run   # lists the files of August 2025 and earlier
cli audit archive-remove --before 2025-09

Failed sign-ins and failed redemptions (a wrong invitation, password-reset or connect code) by someone not signed in are recorded one by one, with their address, while two allowances hold: ten per address for each kind of failure (then one every 30 seconds) and thirty per kind for the installation (then one every two minutes). There are ten kinds (a failed local, passkey or single sign-on sign-in, an identity provider's refusal with or without a sign-in this gateway started, an identity no role is given to, and a wrong invitation, passkey invitation, password-reset or connect code), each with its own allowance, so a flood of any of them never hides the records of another. Beyond that they are counted into one login.failures record a minute at most, with the count per kind, the five busiest addresses and, for identities no role is given to, the five busiest subjects: at most about 8,900 records (about 2.3 MB, measured) a day from people not signed in, however many addresses they use. A refusal after the identity provider authenticated someone no role is given to is recorded with the subject and its groups, the first three for each subject and then one every ten minutes, so that an identity provider that authenticates people outside your organization (a public provider, a realm with self-registration) cannot add records without limit. A Command Arsenal dry run (POST /arsenal/execute with execute=false) of one of the reviewed reads (the commands a service token may run, Hunt's read-only tools among them) is one engine.read record that names the command. Any other dry run may write (the engine runs every read-only command in process, and some write records: scope.preview, campaign.create, hypothesis.record and others), so it is recorded as a change (engine.request naming the command, and engine.response), kept and archived like any other change.

An installation upgraded from a gateway before 0.2.0-beta.44 also holds every engine request it recorded before the upgrade, reads included, as engine.request/engine.response records. Retention treats them as changes: they are archived and removed after the change retention (an upgrade removes no audit history). Removing the reads among them sooner is an administrator's decision: audit prune-legacy-reads first writes every record it will remove to a new file, in the audit export's format (NDJSON), then removes them and records that in the audit log with the file's SHA-256. It never removes the records of changes. From the deployment directory:

cli audit prune-legacy-reads --older-than-days 30 --dry-run    # counts, changes nothing
cli audit prune-legacy-reads --older-than-days 30 --export /data/legacy-reads.ndjson
docker compose -f compose.json exec -T enterprise cat /data/legacy-reads.ndjson > legacy-reads.ndjson
docker compose -f compose.json exec -T enterprise rm /data/legacy-reads.ndjson

Keep that file with your audit exports. Without --export the command refuses; --without-export removes the records with no copy, and the audit log says so.

restore.sh refuses to run over existing identity state, a non-empty results directory or an engine database that already holds customer records (targets, scans, findings, Hunts, credentials, devices, accepted-risk exceptions and the like, though not custom device policies on their own; --replace-database overrides the last check deliberately). Before it stops anything or restores any data it checks the whole backup: every payload against the backup's MANIFEST, both archives, the identity snapshot (decrypted and validated for this installation by cli restore --check, which stages it in a private directory on the gateway's state volume and removes it; one that a killed check leaves behind is removed by the next check or restore and when the gateway starts), the whole database dump, the engine version (a backup of a newer engine than the deployment's, or of another major version, is refused; an older one is converted by the engine when it first starts), and the free space for the database (with room for the write-ahead log Postgres writes while it loads: up to its max_wal_size, 1 GB by default, and more while a checkpoint runs) and the results. A wrong passphrase or a damaged backup is refused with its reason. Two things come before those checks: on a new host the deployment files are extracted, and the engine database is started (which creates an empty database volume) so that it can be checked for customer records. A state check that cannot get an answer is a refusal. A refusal puts back the services that were running when the restore started (a container that was restarting counts as not running, and a service compose.json no longer declares is left out): before the stop it changes nothing else, apart from stopping the engine database again if it was not running; after the stop it starts those services again with docker compose up -d, which also starts what they depend on. On a new host the extracted deployment files stay in place.

Just before the stop it writes restore-in-progress in the deployment directory (which backup, each step once it is done, and when the identity step started). Then it restores, in this order, the engine database, the evidence and results, and the identity (people, factors, service tokens, audit, with the MFA key; this revokes every session and service token). Each one is put in place only once it is whole (except results on a filesystem of their own, below). The database dump is loaded into a new, empty database, whatever a fresh volume was initialized with, and replaces the engine database only once it is complete, so a failure while loading leaves the engine database as it was and counts as a refusal. The partly loaded copy is dropped; when that fails too, the refusal names it and the command that drops it, and the next restore drops it first. A volume that fills during the load anyway (something else writing to it, or a database larger than its estimate) stops Postgres, and on the full volume it does not start again, the engine database included, until you free space there, as the refusal says. With --replace-database the database it replaces stays until then, so the volume needs room for both; the refusal says so (remove the volumes first instead if that room is not there). Copies of the engine database that a console engine update's rollback left (<database>__rollback, <database>__replaced) are dropped, and their room counts. The results are extracted next to the results directory (results.restoring) and renamed into place in one step; a results directory that is a filesystem of its own is extracted in place. A failure after the database is loaded leaves restore-in-progress in place and prints what the host now holds (for example "engine database: restored from the backup; evidence and results: not restored yet; identity: not restored yet"), the command to continue, which is the same command again (it resumes the restore of the same backup at the step that failed), and how to abandon the restore instead. While that file exists the systemd units do not start the stack, the scheduled backup and the engine updater; do not start it by hand either. Units installed by 0.2.0-beta.44 or older do not hold back; restore.sh names them, and until you reinstall them (Start at boot) do not reboot during a restore. A restore of another backup is refused until that one is finished, or removed as the message says.

If the stack was started anyway, the rerun notices it: identity state, results or customer records in the engine database that the restore did not write, or the deployment's services running once the database is restored, mean that the stack has run since the restore stopped (what the restore writes carries its mark: a comment on the database, a mark file in the results, the identity restore's own audit record). The rerun then changes nothing and offers two ways on: keep the deployment as it is now (remove restore-in-progress; the restore is abandoned), or restore the backup over it from the start, discarding what was done since (docker compose down -v, move the results away, remove the record, run the restore again). When the stack created identity state after the database and the results were restored, it also offers restoring the identity alone over it (stop the stack, remove the identity state, run the restore again). A rerun right after a dropped SSH session, or the next restore after one was abandoned, first stops this deployment's restore helper containers that were still running. Customer records in an engine database the restore had not replaced yet are replaced only with --replace-database, as on a first run. What it cannot tell apart: results the stack wrote into a results directory of its own filesystem after the results step started there. An identity database the rerun finds without its MFA key (a gateway image older than this kit moves the two one at a time, and can be stopped in between) is restored again. A gateway of this release never creates a new MFA key beside enrolled authenticator apps, whose codes would all fail with it: it refuses to start and names the key file. Put the key back (the backup holds it: finish the restore), or, if it is lost for good, create a new one deliberately (32 random bytes, readable by the gateway user only) and remove each person's authenticator app with the operator CLI's reset-mfa.

A backup taken by a gateway of 0.2.0-beta.44 or older names that gateway's image, which cannot check the identity backup in advance. restore.sh says so and checks it when it restores it, at the last step: a wrong passphrase shows only there. Correct the passphrase file and run the same command again; it continues at that step.

A full drill on one host (2026-11-09: a clean Ubuntu 26.04 install of 0.2.0-beta.50 with engine 2.12.1, three people with passkeys and authenticator apps, a completed scan with 28 findings and a schedule): the backup service (6 s), docker compose -f compose.json down -v, moving the results directory away with the command restore.sh names (it refuses a non-empty one), restore.sh (26 s) and docker compose -f compose.json up -d --wait (14 s) brought back the targets, the scan, its findings and evidence, the schedule, the licence and every person's passkey and authenticator app; everyone signs in again, as the restore revokes sessions. The database restore's failure, interruption and resume paths are tested by automated tests. Note that down -v also removes Caddy's certificate volume, so the certificate is re-issued on the next start; Let's Encrypt allows five duplicate certificates per week per name. Keep the enterprise-tls-data volume if you plan repeated restores.

Restore onto another host or directory

A backup holds everything the deployment needs except Docker, the engine runtime, the kit and the backup passphrase: the deployment files (compose file, settings, Caddyfile, licence, secrets, and your own TLS certificate if you gave prepare one), the engine runtime's .env (the datastore passwords the volumes were initialized with) and its digest lock as engine.env and engine-lock.env, the engine database, the evidence and results, and the identity with the audit archive (identity.bak and, from 0.2.0-beta.46, audit-archive.bak, both encrypted with the backup passphrase). On the new host, as root (Docker installed as in Requirements), with the backup directory copied over and the passphrase file kept separately:

# 1. the kits and the backup passphrase. At the path the systemd units use, the kit of the gateway
#    image the backup was taken with ($IMAGE: compose.json in deployment-files.tar names it, and
#    the restored deployment runs it); beside it, for restore.sh, the kit of this release or a
#    newer one ($NEW_IMAGE, from your delivery note)
echo "$PULL_TOKEN" | docker login ghcr.io -u <pull user> --password-stdin
install -d /opt/shakerscan
docker run --rm --user "$(id -u):$(id -g)" -v /opt/shakerscan:/out "$IMAGE" \
    python -m shakerscan_saas.enterprise.cli kit --output /out/kit
docker run --rm --user "$(id -u):$(id -g)" -v /opt/shakerscan:/out "$NEW_IMAGE" \
    python -m shakerscan_saas.enterprise.cli kit --output /out/restore-kit
install -d -m 700 /etc/shakerscan
install -m 600 <copied passphrase file> /etc/shakerscan/backup-passphrase
# 2. the same engine version (MANIFEST's engine_version), installed without starting it
SHAKERSCAN_HOME=/srv/shakerscan-public-release SHAKERSCAN_INSTALL_VERSION=<engine version from the backup manifest> SHAKERSCAN_START=0 \
    sh -c 'curl -fsSL https://install.shakerscan.com | sh'
cp <backup>/engine.env /srv/shakerscan-public-release/.env
chmod -R a+rX /srv/shakerscan-public-release && chmod 600 /srv/shakerscan-public-release/.env
# 3. the restore, with the newer kit's restore.sh
BACKUP_PASSPHRASE_FILE=/etc/shakerscan/backup-passphrase \
    /opt/shakerscan/restore-kit/self-hosted/restore.sh /srv/shakerscan-enterprise <backup>

Take restore.sh from this release's kit or a newer one whatever gateway the backup is from: the restore.sh of 0.2.0-beta.44 and older cannot restore into a freshly initialized engine database (it fails at its last step), and a newer one restores every older backup. The backup format is the same, and the restored deployment keeps running the gateway image its compose.json names, the backup's, with the systemd units from that image's kit; upgrade it afterwards as in Upgrade when you choose.

At the same paths as the old host (above), step 3 runs the whole restore. In another deployment directory it extracts the deployment files there and stops with the re-render to do first, with the backup's kit and image: /opt/shakerscan/kit/self-hosted/upgrade.sh "$IMAGE" <engine-dir> <deployment-dir>; run the same restore.sh command again after it. Then install the three systemd units, which a restore does not do (without them nothing starts the stack at boot, takes the daily backup or applies updates from the console), and start the stack:

kit=/opt/shakerscan/kit
for unit in shakerscan-enterprise.service shakerscan-enterprise-backup.service shakerscan-enterprise-backup.timer \
    shakerscan-enterprise-updater.service shakerscan-enterprise-updater.timer; do
  sed -e "s|[^ =]*/shakerscan-kit|$kit|g" \
      -e "s|[^ =]*/\.shakerscan-backup-passphrase|/etc/shakerscan/backup-passphrase|g" \
      "$kit/self-hosted/systemd/$unit" > "/etc/systemd/system/$unit"
done
systemctl daemon-reload
systemctl enable --now shakerscan-enterprise.service
systemctl enable --now shakerscan-enterprise-backup.timer shakerscan-enterprise-updater.timer
systemctl start shakerscan-enterprise-backup.service    # the first backup of this host

With other paths, replace /srv/shakerscan-enterprise, /srv/shakerscan-public-release and /var/backups/shakerscan in the units too, as the installer does. Until the first backup on the new host, the console's Backups check says that this host has not taken one since the restore (a gateway of 0.2.0-beta.44 or older still shows the old host's last backup until then, so take that first backup right away, as above).

The deployment settings come back with the backup, the trusted front proxies included (see Behind your own proxy or tunnel; they are in deployment.json under options). If this host sits behind other proxies than the old one, or none, change them before people use it: a listed address that something else connects from here can name any client address.

The restore revokes every session and service token: people sign in again, and every agent, script or CI job needs a new service token (API access), so shakerscan connect again on each client. It also withdraws what was outstanding when the backup was taken: open invitations, password-reset codes, connect links, approvals and pre-authorizations; issue new ones where needed. A restored gateway of 0.2.0-beta.44 or older (it restores the identity of a backup it made) revokes the sessions and service tokens only: its reset codes and open invitations stay valid until they expire, an hour and three days after they were issued.

The restored gateway keeps the original public URL. Point DNS for that name at the new host before people use it; with the same name, passkeys (bound to it) and your own TLS certificate keep working, and a Let's Encrypt certificate is obtained again once the name resolves to the new host (Caddy's certificate volume is not in the backup). Moving to a new URL means re-preparing the deployment with it and is not covered by this procedure: it needs a certificate for the new name, a new redirect URI at your identity provider, and every passkey stops working (people sign in with their password and authenticator app and enroll a new passkey).

6. Upgrade

Two things can change: the public engine release and the Enterprise gateway image. Upgrade the engine runtime in place with the public installer (it keeps the .env the deployment was initialized with and refreshes the digest lock), then re-render with the gateway image you were given (the current one if only the engine changed), pull, and restart:

SHAKERSCAN_HOME=/srv/shakerscan-public-release SHAKERSCAN_START=0 sh -c 'curl -fsSL https://install.shakerscan.com | sh'
chmod -R a+rX /srv/shakerscan-public-release && chmod 600 /srv/shakerscan-public-release/.env
self-hosted/upgrade.sh ghcr.io/andriyze/shakerscan-enterprise@sha256:<digest> /srv/shakerscan-public-release /srv/shakerscan-enterprise
docker compose -f /srv/shakerscan-enterprise/compose.json pull
docker compose -f /srv/shakerscan-enterprise/compose.json up -d --remove-orphans

upgrade.sh renders with the delivery kit of the gateway image you pass it. When your kit is a different version, it installs the image's kit over yours and continues with it, so the console's engine updater always runs current scripts too. Afterwards the kit holds exactly the image's kit: guides and scripts your kit had and the new one no longer ships are removed, and so is compiled Python bytecode (__pycache__, which the 0.2.0-beta.45 kit shipped). A file of your own beside the scripts stays, and nothing outside the kit directory is touched. A kit older than 0.2.0-beta.33 cannot do this: before your first upgrade to 0.2.0-beta.33 or later, extract the new image's kit once in place of your old one (docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/out" "$IMAGE" python -m shakerscan_saas.enterprise.cli kit --output /out/shakerscan-kit, into an empty directory).

From the 2.11 gateway release on, an administrator can also start both kinds of update from the console's Deployment card instead: the host updater the installer enabled applies them one at a time, after a backup, and puts the previous version back if anything fails. If an engine update's rollback cannot put the engine database back (too little room on its volume, for example), the engine stays stopped, and the console says so and names the command that finishes the rollback once the cause is fixed (self-hosted/updater.sh --rollback <engine-dir> <deployment-dir>, as root; it also puts back the engine directory and the previous rendering); engine-rollback-incomplete in the deployment directory says the same, and until then the console's updates are refused. It stays stopped after a reboot too with the boot unit of this release or newer; a unit installed by 0.2.0-beta.44 or older starts the engine at boot, so reinstall the units (Start at boot) or do not reboot until the rollback is finished (the record and the updater's log warn when the installed unit is an older one). A gateway update is offered only from ShakerScan's signed stable channel, asks for your re-authentication, and needs cosign on the host (the updater verifies the image's signature before pulling it). The move onto the first gateway release with this feature is done by hand as above. When the card says the channel could not be read, the running gateway is not affected: until a release is published on the channel (or while the host cannot reach it), upgrade by hand as above. A host without that egress can be given the signed channel as a file: put it in the deployment's secrets/ directory and set SHAKERSCAN_ENTERPRISE_GATEWAY_CHANNEL_FILE=/run/enterprise-secrets/<file name> in enterprise.env.

Updating from 0.2.0-beta.45 or older:

  • Security events and changes made on the engine are kept 400 days and then archived (they were kept forever). To keep them forever as before, set SHAKERSCAN_ENTERPRISE_AUDIT_SECURITY_RETENTION_DAYS=0 and SHAKERSCAN_ENTERPRISE_AUDIT_CHANGE_RETENTION_DAYS=0. A read retention you set to 0 (forever) or to more than 400 days, with no security retention set, keeps security events forever or as long as the reads, as before.
  • The console's update backs up first, with the gateway that is running. A gateway of 0.2.0-beta.45 or older cannot take that backup when something fills its container's /tmp (its health card says only that the backup failed; docker compose -f compose.json exec enterprise df -h /tmp shows it full) or when its identity database is over 64 MiB. Recreate the gateway container, whose /tmp starts empty, and update again: docker compose -f compose.json up -d --force-recreate enterprise. For a database over 64 MiB, shrink it as that gateway's health card says (Identity backup size: audit retention), or update by hand as above and run backup.sh as soon as the new gateway is up: it streams the identity backup with no size limit.
  • Going back to 0.2.0-beta.45 after this release's audit retention archived records (by hand, or a restore of a backup taken before the update) leaves those records on the host only: 0.2.0-beta.45's backup.sh does not back up /data/enterprise/audit-archive, its restore ignores audit-archive.bak, and its audit log no longer holds the archived records. Copy the archive off the host first (docker compose -f compose.json cp enterprise:/data/enterprise/audit-archive ./audit-archive) and keep it with your backups; do not remove the directory, which this release carries on with when you update to it again. A console update that fails and puts the earlier state back within its own window loses nothing: the records archived meanwhile come back into the log, and once this release runs again it archives them again and cli audit verify counts the earlier parts as bytes no record names.

If a release changes the deployment's volume layout, upgrade.sh stops and names the change (for example "adds postgres-cluster; drops postgres-data"): the dropped volume's data would not be carried over. Do not proceed on a deployment with data you need; ask your ShakerScan contact. To accept it deliberately, rerun with that exact text: ACCEPT_VOLUME_CHANGE='<the change>' self-hosted/upgrade.sh .... The dropped volume is kept.

Back up before upgrading. upgrade.sh re-renders compose.json, the Caddyfile and deployment.json for the new release with the same project and volume names, so engine data and identity state persist; it does not touch enterprise.env, secrets/ or results/. It refuses an engine downgrade, a major engine version change and a release that changes the volume layout, and it warns when the target engine release has not yet been reviewed for the gateway. The previous rendering is kept under previous/<timestamp>/; rollback is moving those files back and running the same up command. A console setting that only the newer release has stays in the gateway's state but is not applied after the rollback; the Settings page names it, every other console setting applies and saves as before, and upgrading again applies it again (the gateway CLI's settings-reset --key <name> removes it for good). That holds from 0.2.0-beta.47 on. A gateway before 0.2.0-beta.47 refuses every console save while such a setting is stored ("not a console setting"), and its settings-reset refuses a key it does not know: before rolling back to one, turn the newer settings off (for beta.47's touch switch, untick it under Settings, Scanning), or remove one afterwards with the host command in Work with an agent. A release directory installed afresh carries different datastore passwords than the running volumes; upgrade refuses it with a message naming the variable. In a measured upgrade with real data, PostgreSQL, Redis and Caddy were not recreated and the stack was healthy again in under a minute with scans, findings, evidence, identities and the license intact.

Exposure and attack-surface monitoring

On by default for a new installation since 0.2.0-beta.47. Every role sees the Exposure page (each target's services: the product and version seen on each port and the CVEs the data lists for them), the ASM page and the dashboard's posture and coverage cards. Operators can run attack-surface recon, test batches and "improve" on demand; administrators can also prune endpoints that no longer answer. Nothing runs by itself: each recon, test or improve is started by a person or an agent, and continuous ASM stays off.

Leave it out at install with --no-attack-surface. On an existing deployment, turn it on or off with ATTACK_SURFACE=on self-hosted/upgrade.sh ... (or off). The choice is recorded in deployment.json and survives later upgrades. A deployment installed before beta.47 keeps the setting it had (off unless it was turned on): an upgrade never turns the views on by itself.

While it is off, Hunt's three reads of these records are refused as well: the attack-surface gaps, ASM activity and exposure graph commands (asm.gaps, asm.activity and exposure.graph.get; the agent tool shakerscan_asm_gaps is the first), whether an agent runs one or submits it as a research decision. Every other Hunt command works as before. Some of this data still reaches Hunt with the switch off, because it does not pass the gateway: a research episode on the engine's autopilot can choose the gaps or activity read itself, Hunt's context for a target carries the target's endpoint coverage counts, and the results of these reads recorded earlier stay in Hunt's command history.

Recon, test and improve each hold a licensed scan slot, like a web scan. One the engine refuses before it queues anything (for example because the target already has an active scan) gives its slot back at once, and so does an improve that finds nothing to do. An active test batch needs an approval receipt bound to the target: the target's standing authorization (recorded when an operator first starts active testing of the target, or authorizes it; with "Require a security-key touch for person decisions from terminals" on, a service token's request records one only with the person's touch) is accepted for every check family. The credential ones (auth, bola) can also take a credential-tier receipt bound to the target. An operator may create one on the ASM page only for the target's own address and host, expiring within 7 days and 15 minutes (see the security overview); any other receipt needs an administrator. Pruning re-checks up to 2,000 known endpoints per run with GET requests from the engine's API service, outside the licensed slots, so it is for administrators only and one prune runs at a time.

The ASM page shows every control to every role, and the gateway refuses what a role may not do: viewers only read, operators cannot prune or create any receipt but a target-bound active or credential one, and the Continuous testing card is refused for everyone. Continuous ASM stays off whatever this setting says: the engine would start those scans in the background without passing the gateway, so they would run outside your licensed slots.

Network scans and connected devices

On by default (from 0.2.0-beta.48). Operators and administrators can add hosts (an IP address or hostname) as targets, start network scans, and use the connected-device view. Nothing is installed on a device: the engine's network worker scans it outward with naabu and nmap (port discovery and service detection), and only from your host. It needs engine 2.6 or later; with an older engine it stays off. To leave it out, install with NETWORK_SCANS=off install.sh ...; on an existing deployment, NETWORK_SCANS=off self-hosted/upgrade.sh ... turns it off (on turns it on again). That choice is recorded in deployment.json and kept by later upgrades and console updates. An upgrade to 0.2.0-beta.48 or later turns it on for a deployment that has it off, unless an administrator set NETWORK_SCANS=off; the change is recorded in the deployment history. The network worker takes about 2 GiB of the host's memory: a new installation sizes the scan workers with it counted (Scan workers and licensed slots, below); an upgrade keeps the workers and the cap the deployment has and warns once if the host looks short of memory. Turning network scans on or off (that upgrade, or a rollback to an earlier gateway) recreates the engine API and the scan workers, and a scan running at that moment fails: upgrade while no scan runs.

A network scan holds a licensed scan slot and authorizes its target first, like a web scan, except inventory discovery (from 0.2.0-beta.50): the discovery Add targets starts with "Discover ports & web services now" (profile inventory, no web checks) is no Scan and takes no slot; it waits its turn on the network worker. The network scans that hold a slot (posture, web checks, connected-device scans) run on that one worker one at a time, so only one of them holds a slot at once; another is refused with "one network scan runs at a time" and nothing is started. With the touch switch on, a service token's network scan runs only on a target a person already authorized, or after the person's touch. When the engine refuses it, for example because the host already has a network scan running, the slot is freed at once and the engine's reason is shown. What it may reach is the same as for web scans and Hunts (what scans may reach).

The scan sees what your host's network path sees: NAT, firewalls and VLANs can hide services.

What scans may reach

Web scans, Hunts and network scans may reach public addresses and, by default (from 0.2.0-beta.48), private ones: 10/8, 192.168/16, shared 100.64/10 (CGNAT, Tailscale) and unique-local IPv6, so you can scan an intranet application or a device on your own network in any environment.

  • Turning private addresses off. Install with --private-network-targets refuse, or on an existing deployment run PRIVATE_NETWORK_TARGETS=refuse self-hosted/upgrade.sh ... (allow turns them on again). The engine then refuses private addresses unless the target's environment is Lab. The choice is recorded in deployment.json and kept by later upgrades and console updates.
  • On upgrade. An upgrade to 0.2.0-beta.48 or later allows private addresses on a deployment that refused them, unless an administrator set PRIVATE_NETWORK_TARGETS=refuse on an upgrade; the change is recorded in the deployment history. Earlier gateways did not record whether refuse was chosen or the default, so if you installed with --private-network-targets refuse on purpose, run that upgrade with PRIVATE_NETWORK_TARGETS=refuse.
  • Never scanned. This deployment's own addresses are refused whatever the setting and the environment say: 172.16.0.0/12, loopback, link-local and cloud metadata. Docker places this deployment's containers in 172.16.0.0/12 by default. Two consequences: a network of yours inside 172.16.0.0/12 cannot be scanned from this host, and if you changed Docker's default-address-pools to another range, the containers there are not fenced; ask your ShakerScan contact before scanning that range. Web scans and Hunts apply this fence from engine 2.11.3; network scans on every engine.

Certificate-transparency monitor

On by default, for a new installation and, from 0.2.0-beta.49, for an upgraded one. The engine's CT monitor watches public certificate-transparency logs for new certificates under the root domains of your active targets, and adds each new name as a target (at most 100 new targets per domain per day; further names are counted, not added). It sends nothing to your targets: it reads the public logs only, so the host needs outbound HTTPS to them. A target it adds is not scanned until a person or agent starts a scan of it (continuous ASM stays off). Every role sees the dashboard's CT monitor indicator. Its start/stop button answers with this setting: the monitor runs exactly while it is on.

Turn it off (or on again) with CT_MONITOR=off self-hosted/upgrade.sh ..., then docker compose -f /srv/shakerscan-enterprise/compose.json up -d --remove-orphans. The choice is recorded in deployment.json and survives later upgrades. The monitor runs in one more container (gungnir-worker, up to 1 CPU and 512 MiB).

Deep scans, custom ceilings and parallel scans

Operators and administrators can choose the Deep budget on New Scan (up to 6 hours and 150,000 requests) and set every custom ceiling under Advanced. Ceilings only lower what the budget allows. Each scan holds one licensed slot while it runs, whatever its budget.

A parallel scan (options.parallel: true on POST /scans, "Parallel — experimental") fans one scan out over several workers. It holds one licensed slot for every worker it can use at once: the budget's maximum workers (Fast 2, Balanced 4, Thorough 8, Deep 16, or a lower Advanced max_workers, at most 20), or fewer when it asks for fewer shards. It is admitted only when that many slots are free and gives all of them back when it ends. One that needs more slots than the licence has is refused with the number to lower max_workers to. A target's stored scan options cannot make its scans parallel (parallel, shards, shard_strategy or auth_state_shards in scan_options is refused): ask for it on the scan, where its slots are counted. Validate a parallel scan's results against a single-worker scan: the engine labels it experimental. With engine 2.11 the New Scan page does not offer the parallel choice on Enterprise yet; send it through the API or the client.

Authentication profiles

On the credentials page, operators and administrators can describe how a target's saved credential proves who it is (the origin it is sent to, a path such as /api/me and the identity it should return) and validate it. A validation sends one read-only GET request with the credential to the target's own origin, under a credential approval for that target, at most once per profile every 30 seconds; it takes no licensed slot. Profiles do not change what scans use: scans still select credentials directly. The engine accepts these requests only while its API is published on loopback (its default); the gateway checks the role and records who made each change.

7. Things worth knowing

  • A replica stopped by hand stays down. Docker applies restart: unless-stopped only when a container exits on its own; after docker kill or docker stop, run docker compose -f compose.json up -d to bring the worker back. A worker that crashes is restarted automatically. Either way the scan it was executing ends failed with a message that replay was withheld because requests may already have been sent; re-run it.
  • Rotating your own certificate: replace secrets/tls/cert.pem (mode 0444) and secrets/tls/key.pem (0400, owner 10001) and run docker compose -f compose.json restart https; Caddy serves the new pair within seconds.
  • prepare needs the runtime directory as the installer produced it (.env plus release-image-lock.env); an engine source checkout or a bare Compose file is refused.
  • The runtime files must be world-readable. A db/init.sql with mode 0600 is silently skipped by PostgreSQL's first start, the volume is then marked initialized without a schema, and the API crash-loops. Fix: chmod -R a+rX the runtime directory, docker compose down -v, start again.
  • A secret the gateway cannot read (wrong owner or mode) stops it with a message naming the chown 10001:10001 to apply.