Offline / air-gapped install bundle
scripts/install.sh normally pulls images from a registry and lets the
worker download the Trivy vulnerability DB from ghcr.io on first boot.
Neither is possible on a host with no outbound network path at all. This
page covers scripts/bundle-offline.sh, which packages everything that
install needs into one tar on a connected host, and
scripts/install.sh --offline <bundle.tar>, which consumes it on the
air-gapped target.
This is a different concern from Private registries (authenticating cdxgen against a reachable-but-credentialed dependency mirror) and from Vulnerability data's Path A/B (mirroring or manually seeding just the Trivy DB, when everything else about the deployment already has network access). Use this page when the target host cannot reach anything at all - no registry, no Trivy DB endpoint, nothing.
What's in the bundle
| Component | Source | Always present? |
|---|---|---|
The six images docker-compose.yml pulls | docker save | Yes |
| Trivy vulnerability DB snapshot | trivy --download-db-only | Best-effort - omitted if trivy is not installed on the build host, or the download fails |
| Trivy Java DB snapshot | trivy --download-java-db-only | Best-effort - a smaller nice-to-have; a missing one only affects jar-to-Maven-coordinate matching |
charts/trustedoss/ | copied | Yes |
docker-compose.yml, docker-compose.dev.yml, .env.example | copied | Yes |
The six images are traefik, postgres, redis, trusca-backend,
trusca-backend-worker, and trusca-frontend. The originating issue for
this feature described "four images"; measured against docker-compose.yml
rather than assumed, the compose stack actually pulls six distinct image
references (the backend-worker image is reused by three services -
worker-scan, worker-default, and beat - but that is still one image to
save and load). An air-gapped docker-compose up needs the whole set or it
stalls on whichever image is missing, so the bundle carries all six.
1. Build the bundle (on a connected host)
Run this on a build server, a developer laptop, or a bastion with internet
egress - anywhere docker (and ideally trivy) is installed and can reach
ghcr.io:
bash scripts/bundle-offline.sh
IMAGE_REGISTRY / IMAGE_TAG default from .env when one is present in the
repo root (the same convention install.sh and backup.sh use), otherwise
they fall back to docker-compose.yml's own defaults. Override either
explicitly:
bash scripts/bundle-offline.sh --tag 0.23.1 --registry ghcr.io/trustedoss
Pick a different output path with -o/--output:
bash scripts/bundle-offline.sh -o /mnt/usb/trusca-offline-bundle.tar
The script pulls any of the six images not already present locally, then
docker saves all of them into one tar, downloads the Trivy DB (and, best
effort, the Java DB) into a scratch cache directory, copies the Helm chart
and the three Compose/env files, and packs everything into a single output
tar. It prints each component's size as it goes, and the final tar's
contents and size at the end.
If a step fails partway through (disk full, docker save error, network
drop mid-download), the script cleans up its scratch directory and removes
any partial output - it never leaves a truncated tar at the final path for
a later run to mistake for a good one.
The bundle is tied to one IMAGE_TAG. Build a fresh one for every version
you plan to install or upgrade to across the air gap - there is no
"latest" bundle to reuse.
2. Transfer it across the air gap
Copy both the tar and its .sha256 sidecar across whatever approved
channel your air gap uses (USB, internal artifact store, a supervised
file-transfer process). The bundle has no dependency on the machine it was
built on beyond the image tags and architecture it was built for - build it
on a host with the same CPU architecture (amd64/arm64) as the target.
install.sh --offline docker loads whatever is inside the tar and runs it
with the same privileges as a normal install - there is no sandboxing. The
checksum in step 3 below catches transit corruption and tampering after
you already have the bundle, not a bundle from a source you should not have
trusted in the first place. Only run --offline against a bundle you built
yourself, or one whose provenance you can otherwise attest to (an internal
build pipeline you control, a colleague you trust and can ask directly) -
never one whose origin you cannot verify.
3. Install with it (on the air-gapped target)
Clone or copy the repository onto the target host (the bundle carries the
Compose files as a convenience copy, but scripts/install.sh itself still
needs to exist there), then run:
bash scripts/install.sh --offline /path/to/trusca-offline-bundle-0.23.1.tar
Combine with --no-prompt for a fully unattended air-gapped install (see
the UAT checklist for the full list of
INSTALL_* non-interactive knobs --no-prompt reads):
bash scripts/install.sh --no-prompt --offline /path/to/trusca-offline-bundle-0.23.1.tar
With --offline set, install.sh:
- Verifies the bundle against its
.sha256sidecar (a checksum mismatch, or a missing sidecar, aborts immediately - nothing below runs against an unverified bundle) and checks every member path inside the tar for a path-traversal or absolute-path shape, before extracting anything. - Extracts the bundle to a scratch directory and
docker loads the six images, then confirms every image the bundle's ownMANIFEST.txtlists is actually present in the local Docker daemon afterward - this replaces thedocker-compose pullstep entirely, so nothing in the normal install flow reaches out to a registry. - Pins
.env'sIMAGE_REGISTRY/IMAGE_TAGto whatever the bundle'sMANIFEST.txtrecorded -.env.example's own default (or anINSTALL_*override) has no reason to match the tag the bundle was actually built with, anddocker-compose.ymlneeds to resolve to the images that were just loaded, not pull a mismatched one from the network. - If the bundle carries a Trivy DB snapshot, seeds the
trivy-cachenamed volume from it (viadocker-compose runagainst theworker-scanservice, so it lands on the correct volume regardless of this deployment'sCOMPOSE_PROJECT_NAME) before the stack starts, and setsTRIVY_DB_BOOTSTRAP_ON_START=falsein.envso the worker does not repeatedly retry an unreachableghcr.ioon every restart. - Runs the rest of the wizard exactly as a normal install - the staged bring-up, the alembic migration, and the super-admin bootstrap are all unchanged.
If the bundle has no Trivy DB (the build host had no trivy installed, or
the download failed), install.sh leaves the worker's normal online
bootstrap enabled - vulnerability matching will simply be unavailable until
that host regains network access, or until you follow
Vulnerability data's Path B to seed the
cache separately.
BomLens engine images
The bundle does not contain the BomLens scan engine
(SCAN_ENGINE=bomlens, a preview). The engine worker pulls its images when a
scan needs them, so to use the engine in an air-gapped install, copy them
into a registry that the target host's Docker daemon can reach:
- the BomLens image
ghcr.io/sktelecom/bomlens - the BomLens image
ghcr.io/sktelecom/bomlens-deep-license - the cdxgen language images under
ghcr.io/cdxgen/that the execution plan chooses for stage 1 (see Stage 1 images)
The docker-socket-proxy image that the bomlens-engine compose profile
starts is not in the bundle either.
TRUSCA runs the BomLens images by digest. Each is a multi-platform index, so copy the whole index; a copy of one platform's image has a different digest, and the pull by the pinned digest then fails. On the connected host, read the pinned references from the repository of the release you install:
grep -A2 -E '^BOMLENS_(DEEP_LICENSE_)?IMAGE_DEFAULT' apps/backend/core/config.py
Each reference is printed as two quoted strings; joined, they read
<repository>:<tag>@sha256:<digest>. Copy each
image with every platform, keeping the digest. <your-registry> is the
registry the target host pulls from:
skopeo copy --all --preserve-digests \
docker://ghcr.io/sktelecom/bomlens@sha256:<digest> \
docker://<your-registry>/bomlens:<tag>
skopeo copy --all --preserve-digests \
docker://ghcr.io/sktelecom/bomlens-deep-license@sha256:<digest> \
docker://<your-registry>/bomlens-deep-license:<tag>
If no single host reaches both registries, copy to an OCI layout directory
first (oci:/path/to/bomlens:<tag> as the destination), carry the directory
across, and copy it into your registry with the same flags.
Then point TRUSCA at the copies, with the same tag and digest:
# In the portal's .env
SCAN_BOMLENS_IMAGE=<your-registry>/bomlens:<tag>@sha256:<digest>
SCAN_BOMLENS_DEEP_LICENSE_IMAGE=<your-registry>/bomlens-deep-license:<tag>@sha256:<digest>
The engine refuses a reference without a digest or tagged latest.
Stage 1 images
The execution plan picks one cdxgen language image per scan for stage 1 and
names it by the published digest. The images and digests a BomLens release
can choose are listed in /usr/local/lib/sbom/cdxgen-images.json inside the
BomLens image. On the connected host, read the list from the BomLens image
you pinned above, and print each image as <repository>:<tag>@<digest>:
docker run --rm --entrypoint cat \
ghcr.io/sktelecom/bomlens:<tag>@sha256:<digest> \
/usr/local/lib/sbom/cdxgen-images.json > cdxgen-images.json
jq -r '.tag as $t | .digests | to_entries[] | "\(.key):\($t)@\(.value)"' cdxgen-images.json
Copy every image the same way as the BomLens images, with every platform and the digest kept, under one path in your registry. The image names and the tag stay the same:
jq -r '.tag as $t | .digests | to_entries[] | "\(.key) \($t) \(.value)"' cdxgen-images.json |
while read -r repo tag digest; do
skopeo copy --all --preserve-digests \
"docker://$repo@$digest" \
"docker://<your-registry>/cdxgen/${repo#ghcr.io/cdxgen/}:$tag"
done
The list has one image per language and the all-in-one cdxgen image, several
gigabytes in total. If you scan only some languages, you can copy only their
images; a scan whose plan names an image the registry does not hold fails
with pulling <image> failed.
Then point stage 1 at that path:
# In the portal's .env
SCAN_BOMLENS_STAGE1_IMAGE_PREFIX=<your-registry>/cdxgen/
TRUSCA passes the value to the execution plan as BomLens's
CDXGEN_IMAGE_PREFIX, and the plan then names each image under it, for
example <your-registry>/cdxgen/cdxgen-alpine-node24:13.3.0@sha256:..., with
the published digest. A final / is added when missing. The value must be a
registry host (with a dot, a port or localhost) followed by a path of at
least one part; a host alone such as reg.example:5000, a scheme such as
https://, credentials, a tag, a digest, spaces or shell characters are
refused, and the scan fails before any container starts. The error names the
rule, not the value. Every image under the prefix is allowed for stage 1, so
point it at a path that holds only the cdxgen images, not at a whole
registry.
The prefix is allowed as a stage 1 repository without adding it to
SCAN_BOMLENS_STAGE1_REPOSITORIES. With that list left empty, the prefix
takes the place of the default ghcr.io/cdxgen/, so a plan that still names
an image outside the prefix is refused. Repositories you do list stay allowed
next to the prefix.
A mirror filled with docker pull, docker tag and docker push holds one
platform under a different digest, and pulling the plan's image by its
digest then fails. A pull-through cache of ghcr.io works too, since it
serves the published digests. Its prefix then runs down to the cdxgen
namespace, for example <your-registry>/<proxy-project>/cdxgen/, not just to
the cache project, which would allow every image the cache can fetch.
Whoever mirrors these images and makes them available to others takes on the license obligations of each image. Their licenses, and where each image keeps its notices and source, are listed under Licences of the engine images.
Helm / Kubernetes
The bundle carries charts/trustedoss/ for a Helm-based deploy, but
docker load only populates one host's local Docker daemon - it does not
help a Kubernetes cluster, whose kubelets pull images through their own
container runtime from a registry, not from docker load on your
workstation. For a Helm install across an air gap you additionally need an
internal registry reachable by every node, and to push the six images
there (docker load the bundle locally, re-tag each image for your internal
registry, docker push) before helm install - the chart's own
image.repository values then point at that internal registry the same way
Private registries documents for the container
scan credential path.
Known limits
- The
.sha256sidecar is required, not optional.install.sh --offlinerefuses to proceed without one matching the bundle - there is no flag to skip this. If you only have the tar (the sidecar was lost or never copied over), regenerate it on the target withsha256sum bundle.tar > bundle.tar.sha256yourself; that only re-proves the tar is internally consistent with a checksum you just made, not that it is the onebundle-offline.shoriginally produced, so do this only for a bundle you already trust by the rule in step 2 above. - One host's Docker daemon.
docker loadon the target populates the local Docker daemon only. On a multi-node deployment (Helm/Kubernetes, or a Compose host with any container runtime other than the one that randocker load), you still need to get the images to every node - see Helm/Kubernetes above. - Architecture-specific. Build the bundle on the same CPU architecture
(amd64/arm64) you intend to install on.
docker savecaptures whatever the local daemon pulled, not a multi-arch manifest. - Trivy DB is best-effort, not guaranteed. A build host without
trivyinstalled, or one that cannot reachghcr.io/aquasecurity/trivy-dbitself, still produces a valid bundle - just one without a Trivy DB snapshot.MANIFEST.txtinside the tar records whether one was included. - Not compressed. Images and the Trivy DB are already compressed
internally; the bundle is a plain
tar, not a.tar.gz, to avoid burning CPU re-compressing multi-gigabyte content for little size benefit. - The repository itself is still needed. The bundle carries a
convenience copy of the three Compose/env files, but
scripts/install.shandscripts/bundle-offline.shthemselves are not inside the bundle - get the repository onto the target host by whatever means your air gap allows (a source tarball, an internal Git mirror, the same transfer channel as the bundle itself).