A Go CLI tool for synchronizing container images, Helm charts, and Git repositories across air-gapped environments.
Universal Airgapper reads a YAML configuration file listing artifacts (container images, Helm charts, Git repos) with their source and destination, then copies each artifact from source to destination. It is designed for environments where registries sit behind an air gap and artifacts must be moved in a controlled, automated way.
- Sync container images between any Docker v2-compatible registries (Docker Hub, GHCR, Harbor, Nexus, private registries)
- Sync Helm charts between OCI registries and legacy HTTP chart repositories
- Sync Git repositories between any hosting services (GitHub, GitLab, Bitbucket, Azure Repos, self-hosted) via HTTPS or SSH
- Helm image extraction -- render Helm charts and extract all container image references, generating a ready-to-use image sync config for air-gapped registry migration
- Regex patterns for tags, chart versions, and git refs -- sync all matching versions automatically
- Generic command scanner -- run any external security/compliance tool before promoting an artifact
- Push modes:
skip(default),force,overwrite-- control what happens when an artifact already exists at the destination - Dry-run mode -- preview what would be synced without making any changes
- Multi-file configuration -- split config across multiple YAML files, merged at load time
- Flexible credential management -- file-based credentials, organized by type, with SSH key support
- Multiple deployment targets: run locally, in Docker, as a Kubernetes Job/CronJob, or in GitHub Actions / GitLab CI
- Structured JSON logging via
slog - Single statically linked binary, zero runtime dependencies
The install script downloads the release archive for your platform, verifies its SHA-256 checksum against the published checksums file, and installs the binary.
When cosign is on your PATH, it also verifies the archive's signature against cosign.pub before installing.
curl -fsSL https://raw.githubusercontent.com/fullstacks-gmbh/airgapper/main/install.sh | shThe script asks for confirmation before it writes anything.
It needs curl, tar, and jq, plus one of sha256sum, shasum, or openssl for the checksum verification.
Without AIRGAPPER_INSTALL_DIR, it installs into the first of ~/.local/bin, ~/bin, or /usr/local/bin that exists and is on your PATH.
Control the script with these environment variables.
| Variable | Default | Effect |
|---|---|---|
AIRGAPPER_VERSION |
latest | Install a specific release, with or without the v prefix |
AIRGAPPER_INSTALL_DIR |
auto | Target directory, created if missing |
AIRGAPPER_OS |
uname -s |
Override the platform (linux, darwin, windows) |
AIRGAPPER_ARCH |
uname -m |
Override the architecture (amd64, arm64) |
AIRGAPPER_YES |
0 |
Skip the confirmation prompt |
AIRGAPPER_QUIET |
0 |
Suppress progress output |
AIRGAPPER_SKIP_CHECKSUM |
0 |
Install without checksum verification, not recommended |
AIRGAPPER_SKIP_SIGNATURE |
0 |
Skip signature verification even when cosign is installed |
AIRGAPPER_COSIGN_KEY |
repo key | Local path or URL of the public key to verify against |
AIRGAPPER_COSIGN_OFFLINE |
0 |
Verify without contacting the Rekor transparency log |
NO_COLOR |
unset | Disable colored output |
Signature verification is best effort. The script skips it when cosign is missing or when the release carries no signature bundle, and it fails the install when cosign is present and verification does not pass.
# Pin a version and install unattended into a custom directory
curl -fsSL https://raw.githubusercontent.com/fullstacks-gmbh/airgapper/main/install.sh \
| AIRGAPPER_VERSION=1.4.3 AIRGAPPER_YES=1 AIRGAPPER_INSTALL_DIR=~/.local/bin shAlternatively, download an archive from GitHub Releases or build from source:
# Build from source (requires Go 1.26.3+)
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o airgapper ./cmd/airgapperOr use the container image:
docker pull ghcr.io/fullstacks-gmbh/airgapper:latestRelease archives, the checksums file, and the container image are signed with cosign using the key pair whose public half is cosign.pub in this repository.
The install script performs this check automatically when cosign is available; the steps below cover manual verification of a downloaded artifact.
Each signed artifact has a matching .sigstore.json bundle in the release assets.
VERSION=1.4.3
BASE=https://github.com/fullstacks-gmbh/airgapper/releases/download/v${VERSION}
ARCHIVE=airgapper_${VERSION}_linux_amd64.tar.gz
# Public key, archive, and signature bundle
curl -fsSLO https://raw.githubusercontent.com/fullstacks-gmbh/airgapper/main/cosign.pub
curl -fsSLO "${BASE}/${ARCHIVE}"
curl -fsSLO "${BASE}/${ARCHIVE}.sigstore.json"
cosign verify-blob \
--key cosign.pub \
--bundle "${ARCHIVE}.sigstore.json" \
"${ARCHIVE}"A successful run prints Verified OK.
Any other output means the archive does not match the signature and must not be used.
Verify the checksums file the same way, then check the archive hashes against it:
curl -fsSLO "${BASE}/airgapper_${VERSION}_checksums.txt"
curl -fsSLO "${BASE}/airgapper_${VERSION}_checksums.txt.sigstore.json"
cosign verify-blob \
--key cosign.pub \
--bundle "airgapper_${VERSION}_checksums.txt.sigstore.json" \
"airgapper_${VERSION}_checksums.txt"
sha256sum --check --ignore-missing "airgapper_${VERSION}_checksums.txt"Verify the container image by tag or by digest:
cosign verify --key cosign.pub ghcr.io/fullstacks-gmbh/airgapper:1.4.3Signatures are also recorded in the public Rekor transparency log, which cosign contacts during verification.
On hosts without internet access, add --insecure-ignore-tlog=true to verify against the public key alone.
Create a configuration file (e.g., config.config.config.airgapper.yaml):
resources:
- type: image
source: ubuntu
destination: registry.internal.corp/mirror/ubuntu
tags:
- "22.04"
- "24.04"# Local binary
airgapper sync --config ./config.config.airgapper.yaml
# Docker
docker run --rm \
-v $(pwd)/config.config.airgapper.yaml:/mnt/config.config.airgapper.yaml \
ghcr.io/fullstacks-gmbh/airgapper:latest \
sync --config /mnt/Configuration files must be named *.config.airgapper.yaml or *.config.airgapper.yml.
When a folder is provided via --config, all matching files are discovered and merged (resource lists are concatenated; scanner definitions are merged by name).
See docs/configuration.md for the full reference.
Sync container images between Docker v2-compatible registries.
resources:
- type: image # also accepts "docker" for backward compat
source: registry.example.com/team/app
destination: internal-registry.corp/mirror/app
push_mode: skip # skip | force (default: skip)
scanner_ref: trivy # optional: run scanner before push
source_credentials_ref: source-reg # optional
target_credentials_ref: internal-reg # optional
tags:
- "v1.0.0"
- "v2\\..*" # regex: all v2.x tags
- "latest"Sync Helm charts between OCI registries and legacy HTTP chart repositories.
resources:
- type: helm
source_registry: registry-1.docker.io
source_chart: bitnamicharts/postgresql
destination_registry: internal-registry.corp
destination_repo: charts
# destination_chart: postgresql # optional target name override
push_mode: skip # skip | overwrite (default: skip)
versions:
- "16.0.0"
- "16\\..*" # regex: all 16.x versionsThe destination OCI artifact name is automatically derived from Chart.yaml, which also handles vendor artifacts whose repository name differs from the real chart name.
Chart pushes include application/vnd.oci.image.manifest.v1+json in the manifest
body and deploy the final version tag directly. This supports Artifactory
registries that otherwise mislabel untagged Helm manifests as Docker schema 1,
causing Helm 4.0.4's digest-fetch/tag step to fail with
mismatch response Content-Type. Chart configuration and layer media types stay
compatible with Helm OCI clients.
Set destination_chart only for an explicit name override.
Sync Git repositories between hosting services via HTTPS or SSH.
resources:
- type: git
source_repo: git@github.com:org/project.git
destination_repo: git@internal-git.corp:mirror/project.git
push_mode: skip # skip | push | force (default: skip)
source_credentials_ref: github-ssh # optional
target_credentials_ref: internal-git-ssh # optional
refs:
- "main"
- "release/.*" # regex: all release branches
- "v[0-9]+\\..*" # regex: all version tagsDefine external scanner commands that run before an artifact is pushed. The scanner is generic -- any command-line tool that returns an exit code can be used.
scanners:
- name: trivy
command: "trivy image --exit-code 1 --severity HIGH,CRITICAL {registry}/{repository}:{tag}"
success_code: 0 # expected exit code for "pass" (default: 0)
timeout: 300 # max seconds (default: 300)
resources:
- type: image
source: ubuntu
destination: internal-registry.corp/mirror/ubuntu
scanner_ref: trivy # references the scanner above
tags:
- "22.04"Available placeholders in the scanner command:
| Placeholder | Description |
|---|---|
{registry} |
Registry hostname (e.g., registry-1.docker.io) |
{repository} |
Repository path (e.g., library/ubuntu) |
{tag} |
Tag / version / ref being synced |
{source} |
Full source reference (e.g., docker.io/library/ubuntu:22.04) |
{type} |
Resource type (image, helm, git) |
scanners:
- name: trivy
command: "trivy image --exit-code 1 --severity HIGH,CRITICAL {registry}/{repository}:{tag}"
success_code: 0
timeout: 300
resources:
# Container images
- type: image
source: ubuntu
destination: registry.internal.corp/mirror/ubuntu
scanner_ref: trivy
tags:
- "22.04"
- "24.04"
- type: image
source: registry.example.com/team/app
destination: registry.internal.corp/mirror/app
push_mode: force
tags:
- "v1\\..*"
# Helm charts
- type: helm
source_registry: registry-1.docker.io
source_chart: bitnamicharts/mariadb
destination_registry: registry.internal.corp
destination_repo: charts
versions:
- "20.2.1"
- "20.2.0"
# Git repositories
- type: git
source_repo: git@github.com:org/project.git
destination_repo: git@internal-git.corp:mirror/project.git
push_mode: skip
refs:
- "main"
- "v.*"Credentials are provided in separate YAML files, organized by type.
Provide them via --credentials (single file or folder).
When pointing to a folder, files must match *.creds.airgapper.yaml or *.creds.airgapper.yml.
See docs/credentials.md for the full reference.
image:
- name: docker-hub
username: myuser
password: mytoken
- name: internal-registry
username: serviceaccount
password: s3cret
helm:
- name: chart-registry
username: helmuser
password: helmpass
git:
- name: github-ssh
ssh_key_path: /path/to/id_ed25519
- name: github-https
username: git
password: ghp_xxxxxxxxxxxx
- name: azure-repos
username: AzureReposAuthnSucks # Azure Repos workaround
password: REPO_AUTH_HEADER # env var name containing the auth headerCredential resolution order:
- By explicit reference (
source_credentials_ref/target_credentials_refin resource config) - By hostname match (credential
namematches the registry/host) - Anonymous access (no credentials found)
airgapper sync Synchronize artifacts from source to destination
airgapper validate Check configuration and credentials without syncing
airgapper helm images Extract container image references from Helm charts
airgapper version Print version, commit, and build date
| Flag | Short | Env Var | Default | Description |
|---|---|---|---|---|
--config |
-c |
AIRGAPPER_CONFIG |
(none) | Path to config file or folder |
--credentials |
AIRGAPPER_CREDENTIALS |
(none) | Path to credentials file or folder | |
--debug |
-d |
AIRGAPPER_DEBUG |
false |
Enable debug logging (JSON, DEBUG level) |
--dry-run |
AIRGAPPER_DRY_RUN |
false |
Disable all write/push operations | |
--log-format |
AIRGAPPER_LOG_FORMAT |
json |
Log format: json or text |
|
--dry-run-log |
AIRGAPPER_DRY_RUN_LOG |
(auto) | Path for the dry-run log file | |
--timeout |
AIRGAPPER_TIMEOUT |
0 |
Overall run timeout in seconds (0 = off) | |
--retry-attempts |
AIRGAPPER_RETRY_ATTEMPTS |
3 |
Tries per network operation, including the first (1 = no retry) | |
--retry-delay |
AIRGAPPER_RETRY_DELAY |
2 |
Seconds before the first retry, doubled for each further retry (with jitter, max 30 s) |
Network operations that fail with a transient error are retried with exponential backoff and jitter: HTTP 408, 429 and 5xx responses, timeouts, reset or refused connections, and truncated responses.
Credential errors (401/403), missing artifacts (404) and a cancelled run are never retried, and --timeout still bounds the whole run, including the waits between tries.
A retry that is logged shows the operation, the attempt and the delay.
airgapper validate checks the configuration and credentials without contacting any registry or host. It reports every problem in one run:
- unknown or misspelled keys, missing required fields, invalid push modes and unknown
scanner_refnames - options that do not apply to the resource type (for example
policy_pathon a Helm or Git resource) - credential references (
source_credentials_ref,target_credentials_ref) that do not exist for the resource type, or are used without--credentials - files that do not exist:
policy_path, theca.crtin*_ca_certdirectories, and thessh_key_pathof referenced credentials
It exits 0 when everything is fine and 2 otherwise.
sync and validate accept --output json (AIRGAPPER_OUTPUT, default text). With it, stdout carries exactly one JSON document and logs stay on stderr, so a pipeline can read the result without parsing text:
airgapper validate --config ./configs/ --credentials ./creds/ --output json
airgapper sync --config ./configs/ --dry-run --output json | jq '.summary'helm images has its own --output flag (the path of the file to write) and is not affected.
| Flag | Env Var | Required | Description |
|---|---|---|---|
--output, -o |
AIRGAPPER_HELM_IMAGES_OUTPUT |
yes | Path to write the generated image config YAML |
--target-credentials-ref |
AIRGAPPER_HELM_IMAGES_TARGET_CREDENTIALS_REF |
yes | Name of a helm credential entry whose name field is the destination registry hostname |
The command reads all helm resources from the config, pulls and renders each chart version with its default values, extracts every image: reference from the rendered manifests, and writes a ready-to-use airgapper image config YAML to --output.
The output file can be fed directly to airgapper sync to mirror those images into an air-gapped registry.
airgapper helm images \
--config ./configs/ \
--credentials ./creds/ \
--output ./image-sync.config.airgapper.yaml \
--target-credentials-ref my-internal-registry| Code | Meaning |
|---|---|
0 |
All resources synced successfully |
1 |
Sync completed with one or more failures |
2 |
Usage or configuration error (also validate finding problems) |
# Sync with a single config file
airgapper sync --config config.config.airgapper.yaml
# Sync with a folder of config files and credentials
airgapper sync --config ./configs/ --credentials ./creds/
# Dry run with debug logging
airgapper sync --config ./configs/ --credentials ./creds/ --dry-run --debug
# Using environment variables
export AIRGAPPER_CONFIG=./configs/
export AIRGAPPER_CREDENTIALS=./creds/
export AIRGAPPER_DRY_RUN=true
airgapper sync
# Print version
airgapper versionairgapper sync --config ./configs/ --credentials ./creds/docker run --rm \
-v $(pwd)/configs:/mnt/configs \
-v $(pwd)/creds:/mnt/creds \
ghcr.io/fullstacks-gmbh/airgapper:latest \
sync --config /mnt/configs/ --credentials /mnt/creds/For Git over SSH, mount your SSH configuration:
docker run --rm \
-v $(pwd)/.ssh:/home/nonroot/.ssh:ro \
-v $(pwd)/configs:/mnt/configs \
-v $(pwd)/creds:/mnt/creds \
ghcr.io/fullstacks-gmbh/airgapper:latest \
sync --config /mnt/configs/ --credentials /mnt/creds/Mount config via ConfigMap and credentials via Secrets.
Example manifests are in k8s/.
apiVersion: batch/v1
kind: Job
metadata:
name: airgapper-job
spec:
template:
spec:
containers:
- name: airgapper
image: ghcr.io/fullstacks-gmbh/airgapper:latest
command: [ "/airgapper" ]
args: [ "sync", "--config", "/mnt/config/", "--credentials", "/mnt/creds/" ]
volumeMounts:
- name: config-volume
mountPath: /mnt/config/config.yaml
subPath: config.yaml
- name: secrets-image
mountPath: /mnt/creds/image.yaml
subPath: image.yaml
restartPolicy: Never
volumes:
- name: config-volume
configMap:
name: airgapper-config
- name: secrets-image
secret:
secretName: airgapper-secrets-imageSame as Job, with a schedule:
apiVersion: batch/v1
kind: CronJob
metadata:
name: airgapper-cronjob
spec:
schedule: "0 0,12 * * *" # Twice daily
jobTemplate:
spec:
template:
spec:
containers:
- name: airgapper
image: ghcr.io/fullstacks-gmbh/airgapper:latest
command: [ "/airgapper" ]
args: [ "sync", "--config", "/mnt/config/", "--credentials", "/mnt/creds/" ]
# ... volume mounts as aboveUse the reusable workflow from this repository. See docs/github-actions.md.
jobs:
sync:
uses: fullstacks-gmbh/universal-airgapper/.github/workflows/universal-airgapper.yml@main
with:
config-folder: ${{ github.workspace }}/configs
secrets:
GHCR_PAT: ${{ secrets.GHCR_PAT }}Use the CI component template. See docs/gitlab-ci.md.
include:
- component: <airgapper-repo-group>/airgapper@1
inputs:
stage: run
config-folder: ${CI_PROJECT_DIR}
credentials-file: ${UNIVERSAL_AIRGAPPER_CREDS}Universal Airgapper follows Hexagonal Architecture (Ports & Adapters) with the Strategy pattern for pluggable transport backends.
cmd/airgapper/main.go # Thin entrypoint
internal/
cli/ # Cobra commands (root, sync, helm images, version)
config/ # YAML config loading, validation, merging
credentials/ # File-based credential store
domain/ # Core types, interfaces, errors (zero deps)
helmimages/ # Helm chart rendering and image reference extraction
transport/
image/ # Container image sync (containers/image v5)
helm/ # Helm chart sync (Helm v4 SDK)
git/ # Git repo sync (go-git v5)
scanner/ # Generic external command scanner
sync/ # Orchestration engine
logging/ # Structured logging (slog)
For the full C4 architecture model and detailed design, see docs/architecture/.
- Go 1.26.3+
- direnv (optional, for automatic env setup)
- Nix (optional, provides reproducible toolchain via
flake.nix) - Docker (for container builds)
# Development build
go build ./cmd/airgapper
# Production build (static, stripped)
CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w -X main.version=$(git describe --tags) -X main.commit=$(git rev-parse HEAD)" \
-o airgapper \
./cmd/airgappergo test ./... # Unit tests
go test -race ./... # With race detector
go test -tags=integration ./... # Integration testsgolangci-lint runContributions are welcome. See CONTRIBUTING.md for development setup, coding standards, testing guidelines, and commit conventions.
This project is licensed under the GNU General Public License v3.0.