Skip to main content

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​

ComponentSourceAlways present?
The six images docker-compose.yml pullsdocker saveYes
Trivy vulnerability DB snapshottrivy --download-db-onlyBest-effort - omitted if trivy is not installed on the build host, or the download fails
Trivy Java DB snapshottrivy --download-java-db-onlyBest-effort - a smaller nice-to-have; a missing one only affects jar-to-Maven-coordinate matching
charts/trustedoss/copiedYes
docker-compose.yml, docker-compose.dev.yml, .env.examplecopiedYes

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.

Rebuild per release

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.

Only install a bundle you can trust

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:

  1. Verifies the bundle against its .sha256 sidecar (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.
  2. Extracts the bundle to a scratch directory and docker loads the six images, then confirms every image the bundle's own MANIFEST.txt lists is actually present in the local Docker daemon afterward - this replaces the docker-compose pull step entirely, so nothing in the normal install flow reaches out to a registry.
  3. Pins .env's IMAGE_REGISTRY / IMAGE_TAG to whatever the bundle's MANIFEST.txt recorded - .env.example's own default (or an INSTALL_* override) has no reason to match the tag the bundle was actually built with, and docker-compose.yml needs to resolve to the images that were just loaded, not pull a mismatched one from the network.
  4. If the bundle carries a Trivy DB snapshot, seeds the trivy-cache named volume from it (via docker-compose run against the worker-scan service, so it lands on the correct volume regardless of this deployment's COMPOSE_PROJECT_NAME) before the stack starts, and sets TRIVY_DB_BOOTSTRAP_ON_START=false in .env so the worker does not repeatedly retry an unreachable ghcr.io on every restart.
  5. 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 .sha256 sidecar is required, not optional. install.sh --offline refuses 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 with sha256sum bundle.tar > bundle.tar.sha256 yourself; that only re-proves the tar is internally consistent with a checksum you just made, not that it is the one bundle-offline.sh originally produced, so do this only for a bundle you already trust by the rule in step 2 above.
  • One host's Docker daemon. docker load on 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 ran docker 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 save captures whatever the local daemon pulled, not a multi-arch manifest.
  • Trivy DB is best-effort, not guaranteed. A build host without trivy installed, or one that cannot reach ghcr.io/aquasecurity/trivy-db itself, still produces a valid bundle - just one without a Trivy DB snapshot. MANIFEST.txt inside 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.sh and scripts/bundle-offline.sh themselves 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).