Air-gapped clusters¶
Some environments can't reach the public internet at all — regulated networks, isolated sites, security-sensitive deployments. CAPVCO can run a cluster with no public-internet egress: every container image is served by a registry inside the cloudspace, and node binaries come from a pre-baked image.
How it works¶
Two sources of egress have to be eliminated, and CAPVCO handles both:
- Node bootstrap — a stock image installs Kubernetes from
pkgs.k8s.ioon boot. A pre-baked image already has it, so no egress. This is a hard prerequisite. - Container images — kubeadm, the CNI, the CSI driver, etc. normally pull from
registry.k8s.io,docker.io, and your portal's registry. CAPVCO stands up an in-cloudspace registry VM and points every node's containerd at it.
flowchart LR
subgraph cs["Cloudspace (no egress)"]
reg["Registry VM (TLS)"]
cp["control-plane"]
wk["workers"]
cp -->|"containerd pulls"| reg
wk -->|"containerd pulls"| reg
end
The controller brings the registry up before the control plane, fetches a whitesky.cloud-issued TLS certificate for it, publishes its DNS name to its in-cloudspace IP, and wires every node's containerd to pull through it (with the cert trusted). CRS add-on manifests stay registry-agnostic — the containerd mirror redirects transparently, so nothing needs image-ref rewriting.
Turn it on¶
Use cluster-template-airgapped.yaml:
VCO_LOCATION=<loc> \
VCO_IMAGE_ID=<prebaked-node-image-id> \
VCO_REGISTRY_DOMAIN=ag-registry.<your-zone> \
VCO_REGISTRY_IMAGE_ID=<registry-image-id> \
VCO_REGISTRY_SEEDED=true \
VCO_REGISTRY_CERT_SECRET=ag1-operator \
clusterctl generate cluster ag1 \
--kubernetes-version v1.36.4 --infrastructure vco --flavor airgapped > ag1.yaml
kubectl apply -f ag1.yaml
The spec.registry fields¶
spec.airGapped: true implies a registry; spec.registry configures it:
| Field | Meaning |
|---|---|
domain |
Required. The registry's DNS name — used for its TLS cert and the containerd mirror config. |
seeded |
true = a pre-seeded, zero-egress registry image (below). false (default) = a registry:2 pull-through cache (needs egress to seed on demand). |
imageId / locationImages |
The registry VM's image id (per location, like node images). |
certSource |
CERTIFICATE_STORE (operator-uploaded, air-gap friendly; default) or LETS_ENCRYPT (needs public reachability). |
certCredentialSecretRef |
Operator credential with access to the SSL-certificate store (per-cluster customer credentials get a 403 here). |
port |
Registry HTTPS port (default 5000). |
mirrorRegistries |
Upstreams redirected to the local registry. Defaults to the set kubeadm/CNI/CSI use; your portal's own registry is derived and mirrored automatically (never hardcoded). |
vcpus / memoryMiB / bootDiskGiB |
Registry VM sizing. |
Seeded vs pull-through¶
- Seeded (
seeded: true) — true zero-egress. The registry VM image has theregistrybinary, a systemd unit, and a pre-seeded image store already baked in (kubeadm's image set, Calico, the CSI driver + sidecars, etc., each stored under its host-stripped repo path so containerd resolves it). The controller just drops the TLS cert and starts the baked service. Nothing is ever pulled from upstream. - Pull-through (
seeded: false, default) — needs egress to warm. The registry runs as aregistry:2pull-through cache, fetching from upstream on first request and caching thereafter. Fine for a low-egress (not zero-egress) setup, or for validating the mirror path.
For a genuinely disconnected environment you want a seeded image. Build one with
image-builder/build-registry-image.sh (the seeded counterpart to the node-image
build): it stages every image an air-gapped cluster pulls — the kubeadm set,
Calico, the CSI sidecars — into a local store with skopeo (each under its host-stripped repo path),
then bakes that store, the registry binary, the capvco-registry.service unit, and the baked
config.yml
into the Ubuntu cloud image with packer's QEMU builder. It needs egress at build time only (to
fetch the images); the resulting cluster needs none. Publish the qcow2 into your location(s) with
capvco-imagepublish (image create is operator-privileged), then set seeded: true and point
registry.imageId at the published id.
Validated end-to-end
The seeded path is exercised by the feature-matrix e2e (scenario_airgapped, seeded by default):
a control plane comes up pulling Calico (docker.io) and kube-proxy/coredns (registry.k8s.io)
entirely from the baked store, reaching Ready with zero upstream pulls.
The TLS certificate¶
The registry serves HTTPS with a certificate from whitesky.cloud's SSL-certificate API. The manager
fetches the cert for domain and pushes it to the registry VM, and a periodic cron re-fetches and
re-pushes so it never goes stale. Nodes trust the registry's CA via containerd.
Cert fetch needs an operator credential
The SSL-certificate store is operator-privileged — a per-cluster customer credential gets a
403 even listing certs. Provide an operator credential via
registry.certCredentialSecretRef (same shape as the cluster credential Secret: jwt +
customer_id). This mirrors the image-publish privilege model.
Verify¶
On a healthy air-gapped cluster, every image resolves locally. You can confirm the containerd mirror is in effect on a node:
cat /etc/containerd/certs.d/registry.k8s.io/hosts.toml
# → redirects to https://<registry-domain>:5000 with the CA trusted
A quick negative test: an image that isn't in a seeded registry (e.g. netshoot) will fail to
pull — which is exactly the isolation you want.
Workers on the airgapped template
cluster-template-airgapped.yaml ships a control-plane-only cluster (no worker
MachineDeployment, no MachineHealthChecks, no cert-rollout block) to keep the zero-egress example
minimal. Add worker pools (and MHCs) as you would elsewhere — just make sure they use the
pre-baked node image and the same CNI/CSI labels.
Current limitation¶
A fully private cloudspace (no external network at all, spec.cloudspace.private: true) is not
yet supported — the endpoint has nowhere public to live and the management cluster can't yet reach an
internal-only endpoint. The air-gapped path today runs on a normal cloudspace with an external
endpoint but zero workload egress (all image pulls local). See
known limitations.
That's the feature set. For the exact fields behind any of it, see the CRD API reference; for every template knob, Templates & variables.