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.
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
| Item | Minimum | Recommended |
|---|---|---|
| Operating system | Ubuntu 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 yet | Ubuntu 24.04 LTS |
| Docker | Docker Engine 24+ with the Compose plugin 2.20+ (docker compose) | Docker 27+ / Compose 2.30+ |
| CPU, memory and disk | What the installer refuses: Host size | Host size |
| Network in | TCP 443 from your users; TCP 80 and 443 from the internet if you use ACME certificates | Restrict 443 to corporate ranges; ACME still needs 80 or 443 reachable by the CA |
| Network out | Docker 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 on | The same; nothing about your installation is sent anywhere at run time |
| Hostname | A DNS name pointing at the host. Caddy obtains and renews a Let's Encrypt certificate automatically | A corporate hostname; use your own certificate through Caddy if ACME is not allowed |
| Operator account | A non-root user in the docker group | The same |
| Verification tools | cosign and the GitHub CLI (gh), used by the installer to check the image signature and build attestations | The same |
| From the vendor | The Enterprise image reference (a digest) and a pull token, the delivery kit it carries, and a license for your installation id | |
| From the public release | The 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 refuses | Installer warns | Recommended | |
|---|---|---|---|
| Memory (GiB) | below 7 | 7 to 14: enough for one concurrent scan | 15 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 20 | 40 GB SSD; 100 GB or more, as evidence grows with retained scans | |
| CPU | not checked | 4 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.
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.
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 theDOCKER-USERchain (andip6tablesfor 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.1reaches 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 everydocker compose downandupwithdocker 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:
| Records | Kept by default | Then |
|---|---|---|
Reads through the gateway to the engine (engine.read, streams, browser capture uploads) | 30 days, SHAKERSCAN_ENTERPRISE_AUDIT_PROXY_RETENTION_DAYS | counted 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 codes | 400 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, backups | always | never 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=0andSHAKERSCAN_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 /tmpshows it full) or when its identity database is over 64 MiB. Recreate the gateway container, whose/tmpstarts 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 runbackup.shas 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.shdoes not back up/data/enterprise/audit-archive, its restore ignoresaudit-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 andcli audit verifycounts 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 runPRIVATE_NETWORK_TARGETS=refuse self-hosted/upgrade.sh ...(allowturns them on again). The engine then refuses private addresses unless the target's environment is Lab. The choice is recorded indeployment.jsonand 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=refuseon an upgrade; the change is recorded in the deployment history. Earlier gateways did not record whetherrefusewas chosen or the default, so if you installed with--private-network-targets refuseon purpose, run that upgrade withPRIVATE_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 in172.16.0.0/12by default. Two consequences: a network of yours inside172.16.0.0/12cannot be scanned from this host, and if you changed Docker'sdefault-address-poolsto 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-stoppedonly when a container exits on its own; afterdocker killordocker stop, rundocker compose -f compose.json up -dto bring the worker back. A worker that crashes is restarted automatically. Either way the scan it was executing endsfailedwith 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) andsecrets/tls/key.pem(0400, owner 10001) and rundocker compose -f compose.json restart https; Caddy serves the new pair within seconds. prepareneeds the runtime directory as the installer produced it (.envplusrelease-image-lock.env); an engine source checkout or a bare Compose file is refused.- The runtime files must be world-readable. A
db/init.sqlwith 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+rXthe 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:10001to apply.