How to Deploy Chainsaw in an Air-Gapped Environment

Advanced 45–60 minutes (first time); 15 minutes for subsequent upgrades Platform / Security Engineers in regulated environments Deployment Enterprise

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.

Update (2026-04): Chainsaw now ships a single 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 trivial scanner runs in-process against a local trivy.db file. It expects two files in its data directory: trivy.db and metadata.json. The data_sources.trivy_db.startup_sync knob in configs/example.yaml controls 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.go marks it offline_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)

FeatureRequiresAction
Trivy DB refreshghcr.io/aquasecurity/trivy-db:2 OCI pullPre-seed trivy.db from the bundle (Step 2) and set data_sources.trivy_db.startup_sync: false
OpenSSF malware feedgithub.com/ossf/malicious-packagesPre-seed or set data_sources.openssf.enabled: false
EPSS refreshepss.cyentia.comPre-seed or set data_sources.epss.enabled: false
Billy assistantopenrouter.aiLeave OPENROUTER_API_KEY blank — Billy returns a friendly “offline” message
Device-code CLI loginhttps://chain305.com OAuthUse direct username/password CLI login instead (chainsaw auth login --server $URL)
E-mail verificationPostmarkLeave POSTMARK_SERVER_TOKEN blank
Turnstile signupCloudflareLeave TURNSTILE_SITE_KEY blank
Paddle billingPaddle APILeave PADDLE_* blank — self-host is free, billing is a no-op
Public telemetryPostHogChainsaw 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

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:

  1. 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.
  2. Config-file bootstrap — set policies: blocks in chainsaw.yaml and 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:

  1. Staging machine (online) — pull the new image, docker save | gzip it, rerun make release-cli for the CLI, refresh trivy.db from ghcr.io/aquasecurity/trivy-db:2, bundle.
  2. Target machine (offline) — transfer the bundle, run docker compose pulldocker compose up -d. Volumes persist across image upgrades; migrations run on container start.
Review 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.db exists on the host (Step 4b)
  • hooks.trivial.db_path in chainsaw.yaml points at it (default /data/trivy.db inside 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_sync is false (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.