Skip to main content

Air-Gapped Installation

For environments without access to ghcr.io, mirror all Helm charts and container images to an internal registry before installing. This page walks through the full mirroring process using ORAS.

Prerequisites

  • ORAS CLI v1.2+
  • Helm v3.8.0+
  • Write access to your internal container registry

Mirror the Helm charts

export REGISTRY=my-registry.corp.com
export CHART_VERSION=0.12.1  # the Cosmonic Control version you are installing

oras copy oci://ghcr.io/cosmonic/cosmonic-control:${CHART_VERSION} \
  oci://${REGISTRY}/cosmonic/cosmonic-control:${CHART_VERSION}

oras copy oci://ghcr.io/cosmonic/cosmonic-control-hostgroup:${CHART_VERSION} \
  oci://${REGISTRY}/cosmonic/cosmonic-control-hostgroup:${CHART_VERSION}

Mirror the container images

Rather than maintain a hardcoded image list that drifts with every release, derive the exact set each chart references with helm template and mirror it. This always matches the CHART_VERSION you set above, including the observability image tags, which are pinned independently of the chart appVersion.

# Collect every image the control-plane and HostGroup charts reference.
images=$(
  {
    helm template oci://ghcr.io/cosmonic/cosmonic-control --version "${CHART_VERSION}"
    helm template oci://ghcr.io/cosmonic/cosmonic-control-hostgroup --version "${CHART_VERSION}"
  } | grep -oE 'image:[[:space:]]*"?[^"[:space:]]+' \
    | sed -E 's/image:[[:space:]]*"?//' \
    | sort -u
)

# Mirror each to your registry, preserving the repository path.
for src in ${images}; do
  oras copy "${src}" "${REGISTRY}/${src#*/}"
done
note

If you pass custom -f values.yaml overrides at install time, pass the same -f flags to the helm template commands above so the mirrored set matches exactly what your install will pull.

The Cosmonic Control documentation is also published as a container image so customers can read it inside an air-gapped environment. Mirror it alongside the other images:

export DOCS_VERSION=0.7.0  # the docs image tag — NOT necessarily CHART_VERSION; see the note below

oras copy ghcr.io/cosmonic/docs:${DOCS_VERSION} \
  ${REGISTRY}/cosmonic/docs:${DOCS_VERSION}
note

The offline docs image and tarball are versioned on their own docs-v* cadence, not the Cosmonic Control version, and the newest published tag can lag the Control release you are installing. Do not assume DOCS_VERSION matches CHART_VERSION (for example, ghcr.io/cosmonic/docs:0.12.1 may not exist while the charts are at 0.10.0). Check the docs release page for the newest tag and set DOCS_VERSION to that.

The Kubernetes manifests that run the docs are below in Deploy the documentation.

Configure registry credentials

If your registry requires authentication, create the pull secret before installing:

kubectl create namespace cosmonic-system

kubectl create secret docker-registry registry-credentials \
  --docker-server=my-registry.corp.com \
  --docker-username=<username> \
  --docker-password=<password> \
  -n cosmonic-system

Install with mirrored images

As of Cosmonic Control 0.11.0, global.image.registry redirects every component (the observability stack, Nexus, the operator, Envoy, and the Traefik ingress with its busybox init), so one value points the whole chart at your private registry. (On 0.10.0 and earlier several components pinned their own registry and had to be overridden individually.)

# air-gapped-values.yaml
global:
  image:
    registry: my-registry.corp.com
    pullSecrets:
      - name: registry-credentials

Before installing, render the chart with your values and check the image lines to confirm nothing still points at ghcr.io; every one should show your registry:

helm template cosmonic-control oci://${REGISTRY}/cosmonic/cosmonic-control \
  --version ${CHART_VERSION} -f air-gapped-values.yaml | grep 'image:'
Traefik ingress images

As of Cosmonic Control 0.11.0 the bundled Traefik ingress (ingress.enabled: true, ingress.provider: "traefik") honors global.image.registry like every other component, and its Traefik and busybox init images are mirrored to ghcr.io alongside the rest. The helm template | oras copy step above already copies them, so global.image.registry in the values file redirects them with everything else. No post-renderer or per-image handling is needed.

(On 0.10.0 and earlier the Traefik manifest hardcoded docker.io and ignored the override, which needed a Helm post-renderer or disabling the bundled ingress. That is no longer necessary.)

If you would rather not run the bundled ingress at all, set --set ingress.enabled=false (or --set ingress.provider=istio) and front Cosmonic Control with your own ingress, pointing it at the ingress Service (workload traffic, port 80) and, if you use it, the perses Service (dashboard, port 8080). Both Deployments keep running, and the istio provider recreates the routes for you.

Install Cosmonic Control from your mirrored Helm chart:

helm install cosmonic-control oci://${REGISTRY}/cosmonic/cosmonic-control \
  --version ${CHART_VERSION} \
  --namespace cosmonic-system \
  --create-namespace \
  --set envoy.service.type=LoadBalancer \
  -f air-gapped-values.yaml

Install the HostGroup from your mirrored chart:

helm install hostgroup oci://${REGISTRY}/cosmonic/cosmonic-control-hostgroup \
  --version ${CHART_VERSION} \
  --namespace cosmonic-system \
  --set image.registry=${REGISTRY} \
  --set image.repository=cosmonic/control-host \
  --set "image.pullSecrets[0].name=registry-credentials"
Authenticated registries

As of Cosmonic Control 0.11.0 the HostGroup pod (and the Traefik ingress) emit imagePullSecrets, so the --set image.pullSecrets above (or global.image.pullSecrets, which both charts read) is enough for the kubelet to pull control-host and the other images from a private registry. The ServiceAccount patch that 0.10.0 and earlier required is no longer needed.

Wait for all components to be ready:

kubectl rollout status deploy -l app.kubernetes.io/instance=cosmonic-control -n cosmonic-system
kubectl rollout status deploy -l app.kubernetes.io/instance=hostgroup -n cosmonic-system

Deploy the documentation

Run the mirrored docs image as a single Deployment with a ClusterIP Service. Front it with whatever ingress your environment already uses (Traefik, Istio, your existing reverse proxy):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cosmonic-docs
  namespace: cosmonic-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cosmonic-docs
  template:
    metadata:
      labels:
        app: cosmonic-docs
    spec:
      imagePullSecrets:
        - name: registry-credentials
      containers:
        - name: nginx
          image: my-registry.corp.com/cosmonic/docs:0.7.0
          ports:
            - containerPort: 80
          readinessProbe:
            httpGet:
              path: /
              port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: cosmonic-docs
  namespace: cosmonic-system
spec:
  selector:
    app: cosmonic-docs
  ports:
    - port: 80
      targetPort: 80

The image is a sealed snapshot built with external integrations disabled, so it makes no outbound requests to Algolia, analytics, or Google Fonts. Navbar and footer links to public marketing pages (cosmonic.com, GitHub, blog) will not resolve from an air-gapped browser.

For environments without Kubernetes, the same content is published as a static tarball (with a .sha256 checksum) on the public docs release page:

tar xzf cosmonic-docs-0.7.0.tar.gz -C /var/www/docs
# serve /var/www/docs with any static web server

The static site is built with trailingSlash: true, so URLs end in / and resolve to <dir>/index.html. Any HTTP server that serves index.html for directory requests will work; python -m http.server and the default nginx try_files $uri $uri/ $uri/index.html configuration both do.

DNS for HostGroup pods

OCI image resolution for Wasm artifacts runs inside the HostGroup pod, separate from the Kubernetes container image pull handled by the kubelet. In constrained environments where cluster DNS cannot reach the internal registry (or where the registry hostname resolves differently from the kubelet's perspective), override the HostGroup's DNS configuration:

# hostgroup-values.yaml
dns:
  policy: "None"
  config:
    nameservers:
      - "10.96.0.10"          # CoreDNS service IP, or a custom resolver
      - "8.8.8.8"
    searches:
      - "cosmonic-system.svc.cluster.local"
      - "corp.example.com"
    options:
      - name: "ndots"
        value: "1"
helm install hostgroup oci://${REGISTRY}/cosmonic/cosmonic-control-hostgroup \
  --version ${CHART_VERSION} \
  --namespace cosmonic-system \
  --set image.registry=${REGISTRY} \
  --set image.repository=cosmonic/control-host \
  --set "image.pullSecrets[0].name=registry-credentials" \
  -f hostgroup-values.yaml

dns.policy accepts the standard Kubernetes values: ClusterFirst (default behavior), Default, ClusterFirstWithHostNet, or None. Set to None to replace cluster DNS entirely with the entries under dns.config. For finer-grained overrides, see the Kubernetes DNS Pod configuration reference.

If the node itself needs a custom resolver (for example, so the kubelet can pull container images), configure that at the kubelet level. On a Kind cluster, use kubeadmConfigPatches with kubeletExtraArgs.resolv-conf to mount a custom resolv.conf into the node.

Caching workload artifacts on the host

Wasm component images are pulled by the control-host at workload startup, separately from the kubelet's container image pull. On hosts behind slow or intermittent links to the registry, set imagePullPolicy: IfNotPresent on the component spec so the host reuses a cached artifact after the first pull instead of re-fetching it on every restart:

apiVersion: control.cosmonic.io/v1alpha1
kind: HTTPTrigger
metadata:
  name: example
  namespace: default
spec:
  ingress:
    host: example.localhost.cosmonic.sh
    paths:
      - path: /
        pathType: Prefix
  replicas: 1
  template:
    spec:
      components:
        - name: http
          image: my-registry.corp.com/components/hello-world:0.1.2
          imagePullPolicy: IfNotPresent

imagePullPolicy accepts Always (re-pull on every start), IfNotPresent (pull only if the artifact is missing from the host cache), or Never (fail if missing). The field is available on component specs in HTTPTrigger, Workload, WorkloadReplicaSet, and WorkloadDeployment manifests. Pair it with an in-cluster registry mirror to keep workloads warm across host restarts.

Host OCI-registry settings

That same host-side pull is tuned by an ociRegistry block on the HostGroup chart (new in 0.11.0, and distinct from image.registry, which is where the host pod's own image comes from):

# air-gapped-values.yaml (HostGroup)
ociRegistry:
  pullTimeout: "2m"            # bound a whole pull over a slow mirror link; unset = 30s
  cacheDir: "/tmp/oci-cache"   # host-side blob cache; unset = re-pull on every start
  allowInsecure: false         # plain HTTP for EVERY pull, host-wide (see caution)
  • pullTimeout bounds a whole pull, not each byte, so size it for the largest component over the slowest link. It also bounds hostPlugins pulls, where a timeout fails host startup rather than one workload.
  • cacheDir gives the host a blob cache. Give it a directory of its own (the host evicts everything it finds there older than an hour, counted from when it was written, not last used), and mount a volume through volumes/volumeMounts if you want it to survive pod restarts. /tmp itself is refused at startup. A cache hit still costs a manifest-digest request that pullTimeout does not cover, so a registry that stalls after the handshake can still hang a start.
  • allowInsecure is whole-host and all-or-nothing. It sends every pull over plain HTTP, so turning it on to reach one in-cluster HTTP registry also downgrades ghcr.io (and any other) pulls and breaks them. For the ordinary case of a mirror behind a private or in-cluster CA, leave it off and use caBundle instead (below), which keeps TLS verification on and works per-registry.

Trusting a private registry CA

The host's OCI client (like the kubelet, and outbound NATS) validates TLS against the container's trust store, not a compiled-in root set. A mirror fronted by a private or in-cluster CA therefore needs that CA mounted, which is what the chart's top-level caBundle block does. It exists on both charts; set it wherever the pull happens (the HostGroup pulls workload components, so it matters most there):

# air-gapped-values.yaml (both cosmonic-control and the HostGroup)
caBundle:
  enabled: true
  existingConfigMap: registry-ca   # or `contents:` for inline PEM, or `existingSecret:`

Two properties to know:

  • The bundle replaces the container's trust store rather than adding to it, so if the host also reaches a public registry the bundle must include the public roots too.
  • existingConfigMap / existingSecret mount via subPath, which does not pick up an in-place CA rotation; roll the pod after rotating (a reloader controller does this automatically). Inline contents rolls the pod on change on its own.

If workloads themselves call HTTPS services behind the same private CA, also set caBundle.egress.enabled: true, which extends the same bundle to component wasi:http egress (a separate trust store from image pulls).