Air-gapped / private-registry install¶
Audience: Platform engineer
This guide installs the Gateway Manager Controller (GMC) on a cluster that
cannot pull from GitHub Container Registry (GHCR) — an air-gapped or
egress-restricted environment. The default install pulls every
image (and the Helm chart) straight from ghcr.io; here you instead relocate
them to a private registry, point the chart at that registry, and authenticate
the pulls. Read install.md first — this guide only covers the
deltas for a private-registry install.
The secure-by-default posture is unchanged: all images stay pinned by digest
(relocation is content-addressed, so digests do not change) and registry
credentials are never committed to values.yaml.
What you relocate¶
GAG references five images plus the OCI Helm chart. Only the GMC image is pulled by the chart itself; the AGC, proxy, wrapper, and worker images are pulled by pods the GMC and AGC provision at runtime.
| Image | Public ref | Pulled by | Where you point it at the mirror |
|---|---|---|---|
| GMC | ghcr.io/actions-gateway/gmc |
the chart's GMC Deployment | gmc.image.repository + gmc.image.digest (chart value) |
| AGC | ghcr.io/actions-gateway/agc |
per-tenant AGC Deployments (GMC-provisioned) | agc.image.repository + agc.image.digest (chart value; GMC injects it as AGC_IMAGE) |
| Proxy | ghcr.io/actions-gateway/proxy |
per-tenant egress-proxy pools (GMC-provisioned) | proxy.image.repository + proxy.image.digest (chart value; GMC injects it as PROXY_IMAGE) |
| Wrapper | ghcr.io/actions-gateway/wrapper |
worker pods (init container the AGC injects, or a read-only image volume) | wrapper.image.repository + wrapper.image.digest (chart value; GMC injects it as WRAPPER_IMAGE, forwarded to each AGC) |
| Worker | ghcr.io/actions/actions-runner (default) |
worker pods (AGC-provisioned) | RunnerGroup.spec.workerImage per tenant — not a chart value |
| Helm chart | oci://ghcr.io/actions-gateway/charts/actions-gateway |
helm install |
the oci:// ref you pass to helm |
The wrapper is on by default: the chart always sets
WRAPPER_IMAGE, and the GMC rejects a floating wrapper tag at startup exactly as it does foragc/proxy, so you must mirror it and pinwrapper.image.digestlike the others. It is a tiny (~2 MB)FROM scratchimage the AGC injects into each worker pod, letting the runner container be the unmodified upstreamactions-runner. See release.md § The worker images.The worker default is the upstream digest-pinned
ghcr.io/actions/actions-runner. The project also publishes a first-partyghcr.io/actions-gateway/worker; if a tenant uses it, mirror that ref instead. Either way the worker image is set per tenant on theRunnerGroup, so it is configured during tenant onboarding, not at chart install. See release.md § The worker images.
Copy the five image digests from the
release notes
for the version you are installing. Throughout this guide, replace
registry.internal/gag with your private registry path.
1. Mirror the images (preserving digests)¶
Copy each image from GHCR to your private registry by digest, so the mirrored
copy is byte-identical and the same sha256: digest pins it on both sides. Run
this from a host that can reach both registries (a bastion / jump host), then
move the registry behind the air gap — or pull to a tarball and load on the
inside.
Use whichever tool you already have. cosign copy
carries the cosign signature and SBOM/provenance attestations along with the
image, so prefer it when you want to keep verifying provenance after relocation:
# Replace <gmc>/<agc>/<proxy>/<wrapper> with the digests from the release notes,
# and X.Y.Z with the release version.
for img in gmc agc proxy wrapper; do
cosign copy \
ghcr.io/actions-gateway/$img:X.Y.Z \
registry.internal/gag/$img:X.Y.Z
done
# Worker (upstream runner image — has no GAG cosign signature):
crane copy \
ghcr.io/actions/actions-runner:2.335.1 \
registry.internal/gag/actions-runner:2.335.1
crane copy or skopeo copy --all (use --all to carry the full multi-arch
OCI index, not just one platform) are equivalent for the copy itself; they do
not relocate the cosign signature, so use cosign copy for the signed GAG
images if you intend to re-verify provenance from the mirror.
Verify the digest is preserved — it must match the release notes:
crane digest registry.internal/gag/gmc:X.Y.Z
# Expected: sha256:<gmc> (identical to the public digest)
Tarball variant (no shared-network bastion):
crane pull … out.taron the outside, transfer the tarball across the gap,crane push out.tar registry.internal/gag/gmc:X.Y.Zon the inside. The digest is preserved.
2. Relocate the Helm chart¶
The chart is an OCI artifact. If your cluster cannot reach ghcr.io, mirror it
too (skip this if you install from a source checkout — the
charts/actions-gateway path needs no registry):
oras cp \
ghcr.io/actions-gateway/charts/actions-gateway:X.Y.Z \
registry.internal/gag/charts/actions-gateway:X.Y.Z
helm pull oci://ghcr.io/actions-gateway/charts/actions-gateway --version X.Y.Z
followed by helm push to the internal registry is an equivalent two-step. If
you use the opt-in v2 CRD chart (actions-gateway-crds-v2), relocate it the same
way.
3. Create the image-pull Secret (the secure pattern)¶
The private registry requires authentication. Create a
kubernetes.io/dockerconfigjson Secret from a credentials file — never
embed registry credentials in values.yaml (it gets committed to Git) or pass
them on a command line (they leak into shell history and process listings).
Write a Docker config file containing a scoped, read-only registry token,
chmod 600 it, create the Secret from it, then delete the file:
umask 077
# Use a robot/read-only pull token, not a human admin credential.
cat > dockerconfig.json <<'EOF'
{ "auths": { "registry.internal": { "auth": "<base64 user:token>" } } }
EOF
kubectl create secret docker-registry private-registry \
--namespace gmc-system \
--from-file=.dockerconfigjson=dockerconfig.json
shred -u dockerconfig.json # or: rm -P / rm -f
kubectl create secret docker-registry also accepts --docker-server/-username/-password
flags, but those place the password in your shell history and the process
table — prefer the --from-file form above.
You will create this Secret in gmc-system (for the GMC pull, this step) and
in each tenant namespace (for the AGC/proxy/worker pulls, step 5).
4. Install the chart with the overrides¶
Install from the relocated chart, overriding each image repository to the
mirror, keeping the digest from the release notes, and referencing the pull
Secret:
helm install gag oci://registry.internal/gag/charts/actions-gateway \
--version X.Y.Z \
--namespace gmc-system --create-namespace \
--set gmc.image.repository=registry.internal/gag/gmc \
--set gmc.image.digest=sha256:<gmc> \
--set agc.image.repository=registry.internal/gag/agc \
--set agc.image.digest=sha256:<agc> \
--set proxy.image.repository=registry.internal/gag/proxy \
--set proxy.image.digest=sha256:<proxy> \
--set wrapper.image.repository=registry.internal/gag/wrapper \
--set wrapper.image.digest=sha256:<wrapper> \
--set 'imagePullSecrets[0].name=private-registry'
imagePullSecretsis attached to the GMC pod only — that is the one workload this chart runs.agc.image.*/proxy.image.*/wrapper.image.*set the references the GMC injects into the gateways it provisions — the AGC and proxy pods, and the wrapper init container in every worker pod; relocating the repository here is enough for the GMC to pull them from the mirror. Their pull Secret is wired separately in step 5.- Digest pinning is unchanged —
gmc.image.digestis still mandatory (rendering fails without it) and the GMC still rejects floating AGC/proxy/wrapper tags at startup. Do not reach forallowFloatingImageTags; relocation keeps the digest.
Many
--setflags are easier to manage in a values file. Put therepository/digest/imagePullSecretsoverrides inair-gapped-values.yamlandhelm install … -f air-gapped-values.yaml. The pull Secret is referenced by name there — its credentials still live only in the Secret from step 3, never in this file.
5. Wire pull Secrets for the runtime workloads (AGC / proxy / worker)¶
The AGC, proxy, and worker pods are created by the GMC/AGC at runtime, so the
chart cannot attach imagePullSecrets to them. Use the standard Kubernetes
mechanism: an imagePullSecrets reference on a ServiceAccount is
auto-injected into every pod that uses that SA. All the relevant ServiceAccounts
live in the tenant namespace.
For each tenant namespace, first create the pull Secret there (repeat step 3 with
--namespace <tenant-ns>), then patch the ServiceAccounts:
TENANT_NS=team-a
# AGC pods run as the fixed AGC ServiceAccount "actions-gateway-controller"
# (one per tenant namespace; the name is not derived from the gateway name).
kubectl patch serviceaccount actions-gateway-controller -n "$TENANT_NS" \
-p '{"imagePullSecrets":[{"name":"private-registry"}]}'
# Worker pods run as "actions-gateway-worker" — this SA also pulls the injected
# wrapper init container, so no separate wrapper Secret/SA is needed.
kubectl patch serviceaccount actions-gateway-worker -n "$TENANT_NS" \
-p '{"imagePullSecrets":[{"name":"private-registry"}]}'
# Proxy pods run as the namespace "default" ServiceAccount.
kubectl patch serviceaccount default -n "$TENANT_NS" \
-p '{"imagePullSecrets":[{"name":"private-registry"}]}'
The GMC creates the actions-gateway-controller and actions-gateway-worker
ServiceAccounts when it reconciles the ActionsGateway CR; create the gateway
first, then patch. The
patch survives GMC reconciliation — the GMC only manages those SAs' labels
and owner reference, never their imagePullSecrets — and it never touches the
default SA at all.
Alternative: if your registry is reachable by the cluster but only needs auth, a cluster-wide approach (e.g. a
default-SA mutating policy, or a registry pull-through cache that injects credentials) also works. The per-SA patch above is the no-extra-tooling baseline.
6. Point the worker image at the mirror¶
The worker image is not a chart value — it is set per tenant. When onboarding
a tenant (see tenant-onboarding.md), set the mirrored,
digest-pinned worker reference on the RunnerGroup:
apiVersion: actions-gateway.github.com/v1alpha1
kind: RunnerGroup
spec:
# Mirror of ghcr.io/actions/actions-runner, digest preserved.
workerImage: registry.internal/gag/actions-runner:2.335.1@sha256:08c30b0a7105f64bddfc485d2487a22aa03932a791402393352fdf674bda2c29
Keep the @sha256: digest — it must match the upstream image you mirrored in
step 1. The worker SA patched in step 5 supplies the pull credentials.
7. Verify¶
# GMC pulls from the mirror and is Running (no ImagePullBackOff).
kubectl get pods -n gmc-system -l app.kubernetes.io/name=actions-gateway
kubectl get pod -n gmc-system -l app.kubernetes.io/name=actions-gateway \
-o jsonpath='{.items[0].spec.containers[0].image}{"\n"}'
# Expected: registry.internal/gag/gmc@sha256:<gmc>
# The GMC injects the mirrored AGC/proxy refs into provisioned gateways.
kubectl get deploy -n gmc-system gmc-controller-manager \
-o jsonpath='{range .spec.template.spec.containers[0].env[?(@.name=="AGC_IMAGE")]}{.value}{"\n"}{end}'
# Expected: registry.internal/gag/agc@sha256:<agc>
# After creating a tenant gateway: its AGC / proxy / worker pods (and the
# injected wrapper init container) pull from the mirror with no ImagePullBackOff.
kubectl get pods -n team-a
Then continue the standard healthy-install verification.
If a pod shows ImagePullBackOff, kubectl describe pod it: a 401/403 means the
pull Secret is missing from that namespace or not referenced by the pod's
ServiceAccount (step 5); a "manifest unknown" means the digest was not preserved
during relocation (step 1).
Next steps¶
- install.md — the full install reference these deltas build on.
- tenant-onboarding.md — onboard a tenant, where the worker image and tenant-namespace pull Secret are configured.
- release.md — published image/chart digests, signatures, and the worker-image distinction.