How to Deploy Chainsaw in an Air-Gapped Environment
Stand Chainsaw up on a network with no outbound internet: bundle the binary, Trivy vuln DB, and upstream artifacts on an online staging box, transfer them across, and run the proxy offline with OpenSSF / Billy / telemetry cleanly disabled.
CHAINSAW_OFFLINE=1
umbrella flag that short-circuits every outbound-network code path in
one switch (Trivy DB auto-update, Billy LLM, telemetry, OpenSSF / EPSS
refresh loops, device-code CLI OAuth). Step 4c below leads with the
umbrella flag; the individual knobs are retained in a Granular
overrides subsection for operators who only want to disable a subset.Overview
Chainsaw’s core — the proxy, policy engine, pull-through cache, and admin dashboard — runs fully offline once its data dependencies are pre-seeded. The network-dependent pieces (the Aqua Trivy vuln DB refresh, the OpenSSF malicious-packages feed, the Billy LLM assistant, device-code OAuth for CLI login) either have offline-compatible modes or can be disabled with env vars.
This tutorial walks through bundling Chainsaw on an online staging machine, transferring it into the air-gapped network, running it with outbound- internet features disabled, and bootstrapping your first admin + policy.
Prerequisites
- An online staging machine with Docker,
docker compose,curl, and ~10 GB of free disk (the bundle is ~2 GB compressed). - An air-gapped target machine with Docker + Docker Compose installed, a postgres-capable data volume, and enough storage for the pull-through cache (start with 50 GB).
- A transfer method between the two — a removable drive, a one-way data diode, an approved file-drop share. This tutorial doesn’t prescribe one.
- An internal package mirror (Artifactory, Nexus, Sonatype, a plain HTTP(S) squid cache) that your offline network can already reach for npm / PyPI / Maven / etc. Chainsaw proxies through your internal mirror — it doesn’t replace it.
- Tutorials 01 and 02 completed at least once on an internet-connected laptop so you know the happy path.
Step 1: Understand What Works Offline vs What Needs Network
Chainsaw’s behaviour splits cleanly along the outbound-network line:
Works fully offline
- The proxy + policy engine + pull-through cache — every ecosystem (npm, PyPI, Maven, NuGet, Composer, Cargo, Docker, Go, Hugging Face, APT, Yum/DNF) routes through internal upstreams you configure per repository.
- Trivy vuln scanning — the
trivialscanner runs in-process against a localtrivy.dbfile. It expects two files in its data directory:trivy.dbandmetadata.json. Thedata_sources.trivy_db.startup_syncknob inconfigs/example.yamlcontrols whether the updater tries to refresh at boot. - Checksum fail-closed enforcement (
CHAINSAW_CHECKSUM_MODE={log,quarantine,block}) — it verifies against the checksums published in the mirrored index. - Typosquat, release-freshness, license, and reserved-namespace policies — purely local, no network calls.
- Provenance is not in that set. Its source is never available to the
offline guard (
core/cli/guard_coverage.gomarks itoffline_guard_no_network), so in an air-gapped deployment it is served from the signed intelligence bundle you sideload, and it is only as fresh as the last bundle you transferred. - Dashboard + API + CLI (CLI login via static token, not device code).
- SAML / OIDC / SCIM — terminate against your internal IdP.
Needs network (disable or pre-seed)
| Feature | Requires | Action |
|---|---|---|
| Trivy DB refresh | ghcr.io/aquasecurity/trivy-db:2 OCI pull | Pre-seed trivy.db from the bundle (Step 2) and set data_sources.trivy_db.startup_sync: false |
| OpenSSF malware feed | github.com/ossf/malicious-packages | Pre-seed or set data_sources.openssf.enabled: false |
| EPSS refresh | epss.cyentia.com | Pre-seed or set data_sources.epss.enabled: false |
| Billy assistant | openrouter.ai | Leave OPENROUTER_API_KEY blank — Billy returns a friendly “offline” message |
| Device-code CLI login | https://chain305.com OAuth | Use direct username/password CLI login instead (chainsaw auth login --server $URL) |
| E-mail verification | Postmark | Leave POSTMARK_SERVER_TOKEN blank |
| Turnstile signup | Cloudflare | Leave TURNSTILE_SITE_KEY blank |
| Paddle billing | Paddle API | Leave PADDLE_* blank — self-host is free, billing is a no-op |
| Public telemetry | PostHog | Chainsaw does not phone home in the self-host build; nothing to disable |
See POLICY_PROXY_MATRIX.md for the per-ecosystem
policy coverage that remains intact offline.
Step 2: Prepare an Offline Bundle (on the online staging machine)
Create a clean working directory and pull down everything you’ll need to ship across.
mkdir -p chainsaw-airgap-bundle/{image,trivy,config,registry,chainsaw-cli}
cd chainsaw-airgap-bundle
2a. The Chainsaw server image
Pull the tag you want to freeze on — use the same version you’ve validated in a staging environment.
export CHAINSAW_VERSION=v0.21.23
docker pull chain305/chainsaw-firewall:${CHAINSAW_VERSION}
docker save chain305/chainsaw-firewall:${CHAINSAW_VERSION} \
| gzip > image/chainsaw-firewall-${CHAINSAW_VERSION}.tar.gz
Also save postgres:15-alpine unless your air-gapped registry already
mirrors it:
docker pull postgres:15-alpine
docker save postgres:15-alpine | gzip > image/postgres-15-alpine.tar.gz
2b. Trivy vuln DB snapshot
Chainsaw pulls the Trivy DB from the Aqua OCI registry (ghcr.io/aquasecurity/trivy-db:2)
and extracts trivy.db + metadata.json into its data directory. Offline,
we do this once ourselves and ship the extracted files:
# Option A: use the upstream trivy CLI if it's already installed.
trivy --cache-dir ./trivy-cache image --download-db-only
cp ./trivy-cache/db/trivy.db trivy/trivy.db
cp ./trivy-cache/db/metadata.json trivy/metadata.json
# Option B: pull the OCI layer directly (works without the trivy CLI).
# Chainsaw expects the standard Trivy DB layer media-type:
# application/vnd.aquasec.trivy.db.layer.v1.tar+gzip
oras pull ghcr.io/aquasecurity/trivy-db:2 -o trivy/
# then tar -xzf the layer file to get trivy.db + metadata.json
Tag the snapshot with the date so you can audit freshness later:
date -u +%Y-%m-%d > trivy/SNAPSHOT_DATE
2c. Chainsaw CLI binaries (optional but recommended)
Bundle the signed CLI binaries your users will need. The release-cli make
target cross-compiles all five platforms and writes SHA-256 checksums:
# In a clone of the chainsaw repo on the staging machine:
make release-cli
cp -r bin/release/* chainsaw-airgap-bundle/chainsaw-cli/
The resulting binaries have https://chain305.com/chainproxy baked in as
the default server URL. If you want a different default (e.g. your
internal Chainsaw URL), run make release-cli RELEASE_SERVER=https://chainsaw.internal.
2d. Upstream registry mirror seed
Chainsaw proxies through an upstream mirror — it does not replace one. If your air-gapped artifact repository (Nexus, Artifactory, Sonatype, etc.) already mirrors npm / PyPI / Maven / etc., you’re done; Step 5 wires Chainsaw to it. If you’re setting up an offline mirror from scratch, that’s a separate exercise out of this tutorial’s scope — Nexus and Artifactory both ship offline-bundle tooling.
2e. Config + compose files
Copy the three files you’ll want on the target:
cp /path/to/chainsaw/docker-compose.yml config/
cp /path/to/chainsaw/docker-compose.override.yml.example config/
cp /path/to/chainsaw/configs/example.yaml config/chainsaw.yaml
cp /path/to/chainsaw/.env.example config/.env
2f. Wrap everything
cd ..
tar -cf chainsaw-airgap-bundle-${CHAINSAW_VERSION}-$(date -u +%Y%m%d).tar \
chainsaw-airgap-bundle/
sha256sum chainsaw-airgap-bundle-*.tar > chainsaw-airgap-bundle.sha256
You now have a reproducible offline bundle. Sign it with your org’s release-signing key if your compliance posture requires it.
Step 3: Transfer to the Air-Gapped Network
This step is deliberately unspecified — different orgs have very different rules. USB drive through an airlock kiosk, one-way data diode, approved file-drop share, sneakernet: pick whatever your security policy allows.
On the receiving side, verify the checksum before unpacking:
sha256sum -c chainsaw-airgap-bundle.sha256
tar -xf chainsaw-airgap-bundle-*.tar
cd chainsaw-airgap-bundle
Step 4: Run Chainsaw Offline
4a. Load the Docker images
gunzip -c image/chainsaw-firewall-0.16.0.tar.gz | docker load
gunzip -c image/postgres-15-alpine.tar.gz | docker load
4b. Stage the Trivy DB into a named volume
Chainsaw reads its Trivy DB from /data/trivy.db inside the container.
Drop the pre-seeded files into a host-bind directory so both the initial
boot and future upgrades read from the same path:
mkdir -p /srv/chainsaw/data
cp trivy/trivy.db /srv/chainsaw/data/trivy.db
cp trivy/metadata.json /srv/chainsaw/data/metadata.json
4c. Air-gap .env overrides
Create /srv/chainsaw/.env (copy config/.env then edit):
# Chainsaw offline posture — see tutorial 44 for rationale.
# ---- The umbrella flag: one line, everything off ----
CHAINSAW_OFFLINE=1 # disables Trivy DB auto-pull, Billy,
# telemetry, OpenSSF/EPSS refresh loops,
# device-code CLI OAuth in one switch.
# ---- Postgres ----
POSTGRES_USER=chainsaw
POSTGRES_PASSWORD=replace-me-with-a-strong-password
POSTGRES_DB=chainsaw
# ---- Dashboard posture ----
CHAINSAW_ENABLE_SIGNUP=false # dashboard signup disabled on air-gap
FORCE_HTTPS=true # assume a TLS-terminating ingress in front
The server logs a single startup line confirming offline mode is active, e.g.:
INFO Offline mode active — Trivy DB auto-update, Billy LLM, telemetry,
device-code OAuth disabled. See tutorial 44. source=env
If CHAINSAW_OFFLINE=1 is set but hooks.trivial.db_path points at a
file that hasn’t been pre-seeded (Step 4b), the server refuses to
start with a concrete error naming this tutorial, rather than silently
serving no-data for every vuln policy.
Granular overrides
If for whatever reason you want to enable some outbound integrations while keeping others off (an OpenSSF mirror internal to the corp network, for example), the individual env vars still work and take precedence where applicable:
# ---- Public-SaaS integrations: leave blank to disable individually ----
OPENROUTER_API_KEY= # Billy LLM (openrouter.ai)
POSTMARK_SERVER_TOKEN= # e-mail verification
TURNSTILE_SITE_KEY= # signup bot check
TURNSTILE_SECRET_KEY=
PADDLE_API_KEY= # billing
PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET=
The YAML equivalent lives under runtime::
runtime:
offline: true
The env var always wins when both are set — a quick CHAINSAW_OFFLINE=0 docker compose up flips the deployment back to online for a bench
test without editing chainsaw.yaml.
4d. Disable outbound data-source refresh in chainsaw.yaml
Edit config/chainsaw.yaml and set startup_sync: false on the feeds
you’re not planning to refresh. The three that matter on air-gap:
data_sources:
openssf:
enabled: false # unless you're periodically sideloading a snapshot
trivy_db:
enabled: true
startup_sync: false # we pre-seeded trivy.db in Step 4b
refresh_interval_seconds: 0
epss:
enabled: false
4e. Point the vulnerability scanner at the pre-seeded DB
hooks:
trivial:
db_path: "/data/trivy.db"
timeout_seconds: 15
4f. Boot
cd /srv/chainsaw
# Copy in the compose files from the bundle the first time.
docker compose --env-file .env up -d
Healthcheck:
curl -fsS http://localhost:8443/healthz
Step 5: Configure Upstream Registries to Point at Internal Mirrors
In the dashboard, navigate to Repositories and replace the default
public upstreams with your internal mirror URLs. Alternatively, do this
declaratively in chainsaw.yaml under repositories[] before first boot:
repositories:
- name: npmjs
format: npm
remote:
url: https://nexus.internal/repository/npm-public/
# skip_tls_verify: true # only if your internal CA isn't in the image
- name: pypi
format: pip
remote:
url: https://nexus.internal/repository/pypi-public/simple/
- name: maven-central
format: maven2
remote:
url: https://nexus.internal/repository/maven-public/
# ...repeat for nuget, crates, composer, gomod, docker-hub, rubygems
If your internal mirror uses a private CA, either mount the CA bundle
into the container (/etc/ssl/certs/ca-certificates.crt) or set
skip_tls_verify: true per-repo. The README call-out is explicit: flip
skip_tls_verify on only in air-gapped or lab environments.
Step 6: Bootstrap an Admin
First-boot admin generation works offline — Chainsaw writes a random
password to /data/generated_password on first run, same as the online
path:
docker compose exec chainsaw-proxy cat /data/generated_password
Log in at https://chain305.com/chainsaw/login with username admin and
that password. Rotate it immediately under Users → admin → Change
password.
If /data/generated_password ever goes missing (ops mistake, volume
restore from a bad backup), reset it from inside the container:
docker compose run --rm chainsaw-proxy --reset-admin-password
Step 7: Apply Policies
Two equally good paths:
- Dashboard-driven — navigate to Policies, click New Policy, mirror the rules you validated in your internet-connected staging environment. See tutorial 04 for the canonical CVSS/EPSS policy.
- Config-file bootstrap — set
policies:blocks inchainsaw.yamland bring the stack up; policies are imported into the database on first boot (subsequent edits go through the DB, not the file). See tutorial 30 for the YAML schema.
For compliance-heavy environments, tutorial 39 (reserved-namespace packs) gives you dependency-confusion defence without any network traffic.
Step 8: Upgrade Path
Chainsaw ships roughly monthly. The offline upgrade loop is the bundle process in reverse:
- Staging machine (online) — pull the new image,
docker save | gzipit, rerunmake release-clifor the CLI, refreshtrivy.dbfromghcr.io/aquasecurity/trivy-db:2, bundle. - Target machine (offline) — transfer the bundle, run
docker compose pull→docker compose up -d. Volumes persist across image upgrades; migrations run on container start.
MIGRATIONS.md before any upgrade that jumps more
than one minor version — a handful of past migrations required an explicit
pre-flight step.Refresh the Trivy DB snapshot at the same cadence the online build does (Aqua publishes every ~6 hours) — weekly or monthly is usually enough for air-gapped environments where the CVE-to-patch window is already gated by internal change control.
Troubleshooting
“Trivy DB not found” / vuln policies always return no-data
With CHAINSAW_OFFLINE=1 set, the server refuses to start when
hooks.trivial.db_path points at a missing file — you’ll see a clean
startup error naming this tutorial rather than a silent “no-data”
policy state at runtime. The fix is the same as before:
/srv/chainsaw/data/trivy.dbexists on the host (Step 4b)hooks.trivial.db_pathinchainsaw.yamlpoints at it (default/data/trivy.dbinside the container)- The container has the bind mount you expect:
docker compose exec chainsaw-proxy ls -la /data/
Without CHAINSAW_OFFLINE=1 (mixed-mode deployments), the older
failure modes still apply:
data_sources.trivy_db.startup_syncisfalse(otherwise the updater tries to refresh on boot, times out, and logs a loud warning)
“Billy unreachable” / Billy tab shows a network error
Expected on air-gap. Billy calls openrouter.ai, which you can’t reach.
With CHAINSAW_OFFLINE=1 set, /api/billy/status returns reason: "offline" and the UI renders a distinct “offline mode” empty state
pointing at this tutorial. Any call to /api/billy/chat returns 503
with a message including the phrase offline mode (CHAINSAW_OFFLINE=1)
so ops knows what to grep for in the logs.
Policy evaluation slow / first-request latency high
On first boot the proxy warms caches against whatever feeds are enabled.
If data_sources.openssf.enabled or trivy_db.startup_sync is left on
in an offline environment, every evaluation blocks on an outbound HTTP
timeout. Set enabled: false / startup_sync: false per Step 4d and
restart the proxy.
Signup page still accessible despite CHAINSAW_ENABLE_SIGNUP=false
The env var is read by the Next.js UI at render time; a stale browser
cache may still display it. Hard-refresh (Ctrl-Shift-R). If the
behaviour persists, check NEXT_PUBLIC_CHAINSAW_ENABLE_SIGNUP — it
inherits from CHAINSAW_ENABLE_SIGNUP by default but can be set
independently.
CLI users get “device-code flow failed” / “device-code login disabled”
With CHAINSAW_OFFLINE=1 set, /api/auth/cli/device/* return 503 with
the message device-code login disabled: offline mode. Have users run
chainsaw auth login --server $YOUR_URL --username <their-user> to
use password auth instead, or bake the server URL into a custom build
(make release-cli RELEASE_SERVER=...) so --server is pre-populated.
Related
- MIGRATIONS.md — upgrade-path changelog
- POLICY_PROXY_MATRIX.md — per-ecosystem policy coverage
- Tutorial 01 — Set Up Chainsaw as Your Organization’s Package Proxy
- Tutorial 02 — Configure Your Package Manager to Use Chainsaw
- Tutorial 24 — Manage Repositories and Upstream Mirrors
- Tutorial 30 — Configure Chainsaw with YAML Imports
- Tutorial 43 — Enforce Chainsaw Organization-Wide