Air-gapped & network-isolated deployments (outbound-only runners, no inbound webhooks)
September 9, 2026 · View on GitHub
If your security team forbids inbound network access into your execution clusters, or they're air-gapped, this is the page. Terrapod's runners connect outbound only (over SSE) and create Kubernetes Jobs locally, so the control plane needs no inbound reach into the execution cluster; VCS integration is polling-first, so it works with no inbound webhooks; and a pull-through provider mirror + CLI binary cache (with an air-gap sealed mode) let runners resolve providers/binaries with no upstream internet for cached platforms. This is Terrapod's core restricted-network & multi-cluster execution design focus — see Why Terrapod. Related: the ARC execution model, the webhook split, and forward-proxy support.
By default, every Terrapod consumer — a human's browser, the terraform CLI cloud block, listener pods in remote clusters, runner Jobs uploading state — reaches the API through a single Ingress. That works fine when the management plane is freely reachable from every network the consumers live in.
It often isn't. Common shapes where it isn't:
- Management plane lives on a VPN-only or tailnet-only hostname that operators can reach interactively, but pods in remote production clusters cannot resolve or route to.
- Management plane sits behind a public LB with an IP allow-list that excludes Kubernetes node CIDRs.
- Operators reach Terrapod via Cloudflare/Tailscale Funnel but agent pods are inside private VPCs.
The fix isn't to give up on the private management plane — it's to add a second internal-only entry point that listener and runner pods can reach over the private network fabric (transit gateway, peered VPC, internal LB), while the management plane stays restricted. That's what the chart's internalIngress: block does.
This is independent of the webhook split. They compose: a deployment can have all three Ingresses, two of them, or just the primary.
Surface split — who needs to reach what?
| Audience | Reaches | Auth | Chart block |
|---|---|---|---|
| Humans (browser, CLI) | UI, admin API, CLI cloud-block, SSO redirect | Session cookie / Bearer API token | ingress (primary) |
| GitHub / GitLab / external run-task services | /vcs-events, /task-stage-results/.../callback | HMAC-signed payload | webhookIngress (optional) |
| Listener pods + runner Jobs from other clusters | /agent-pools/join, /listeners/.../events, /runs/.../artifacts/..., registry downloads | X.509 listener cert / runner token | internalIngress (optional) |
All three terminate at the same <fullname>-web Service — the Next.js BFF. The difference is which network the request entered through, not what API it talks to. Listener auth (bearer cert), runner-token auth, and TFE-V2 session auth all work uniformly regardless of Ingress.
Outbound — what the API itself reaches
The table above is about traffic coming in. This one is the half that decides whether "air-gapped" is achievable for you, and it is the part most easily glossed over.
Terrapod can run with no internet. It cannot run with no egress from the API. Every upstream below can be redirected to something inside your network, and all of them honour the forward proxy and custom CA — but the API always needs a path to something. The caches in particular are pull-through: they have to be filled before they can serve, and only the API can fill them.
| The API reaches | When | Removing the internet dependency |
|---|---|---|
| terraform / tofu / terragrunt release hosts | Filling the binary cache | Point the download base + version index at an internal mirror |
| OPA / Trivy / Checkov release hosts | Filling the binary cache for the platform tools — OPA when a policy set applies, the scan engines when scanning is on | Point registry.platform_tools.*_mirror_url at an internal mirror. Checkov has no checksum file upstream, so a mirror that cannot serve the GitHub release API needs platform_tools.verify: off |
| Upstream provider registries | Filling the provider network mirror | Point at an internal registry mirror |
| GitHub releases (Terrapod's own provider) | Serving the platform provider | Internal mirror override |
| The cost pricesheet object | Cost estimation (on by default) | Pre-seed the cached object, or set cost_estimation.prices_url to an internal copy |
| Your VCS host | Polling branches and pull requests | Already internal if you self-host GitHub Enterprise or GitLab |
| Your identity provider | SSO discovery / JWKS | Already internal if the IdP is |
| Slack, webhook and SMTP targets | Notifications, if configured | Point at internal endpoints, or leave notifications off |
| External run-task services | Run tasks, if configured | Internal services, or leave run tasks off |
| A model endpoint | The AI layer, off by default | Self-hosted model, or leave it off |
| The peer node | HA replication, if configured | A private link — never the internet |
Sealed mode is the end state, not the starting state. registry.cache_only: true is a hard guarantee that the caches never reach upstream — but it is something you switch on after the caches hold what you need. The documented order is: populate with cache_only off (upstream overrides pointed at your internal mirrors, then bulk warm), then seal. See Sealed (cache-only) mode, which spells out the workflow and the cache-miss behaviour.
The consequence worth planning for: a sealed deployment cannot fetch a terraform version or provider nobody warmed. That is the intended behaviour — it fails with an actionable error rather than silently reaching out — but it means cache population is an operational task with a lifecycle, not a one-off at install.
Helm values
ingress: # primary — humans + CLI + SSO redirect
enabled: true
className: tailscale # or "nginx" with VPN-restricted LB, etc.
hostname: terrapod.example.com
tls: true
webhookIngress: # optional — public inbound (VCS webhooks)
enabled: true
className: nginx-public # or "alb", "cloudflare", "tailscale" with funnel
hostname: terrapod-webhooks.example.com
tls: true
internalIngress: # optional — internal-only (listener + runner)
enabled: true
className: traefik-internal # or internal-LB-class, "alb" with scheme=internal, etc.
hostname: terrapod-internal.example.com
tls: true
# paths defaults to ["/"] — the internal route is trusted, no allow-list needed
The chart validates each block:
*.hostnamemust be set when*.enabled: true.web.enabled: trueis required (every Ingress routes to the web BFF).webhookIngress.pathsmust be non-empty whenwebhookIngress.enabled(default ships a path allow-list).internalIngress.pathsmust be non-empty (default is["/"]).
Listener apiUrl + publicApiUrl — the runner-side asymmetry
The listener reads two URLs from its runner ConfigMap (runners.yaml — non-sensitive config, not Deployment env):
| Helm value | runners.yaml key | Meaning |
|---|---|---|
listener.apiUrl | server_url | The URL the listener (and the runners it spawns) actually calls. In a split-networking deployment, this is the internalIngress hostname. |
listener.publicApiUrl (default: api.config.external_url, or empty if both are unset) | public_api_url | The public/canonical hostname users see in their browsers, in the CLI cloud block, and in source = "..." registry URLs in user .tf code. Empty means "no redirect needed" — the listener still works, runner Jobs just don't get a host{} block. |
When the two values differ, the listener sets TP_PUBLIC_API_URL in each runner Job pod's env (alongside TP_API_URL). The runner entrypoint detects the mismatch and appends a terraform CLI host{} block to TF_CLI_CONFIG_FILE:
credentials "terrapod.example.com" {
token = "<runner token>"
}
host "terrapod.example.com" {
services = {
"modules.v1" = "https://terrapod-internal.example.com/api/tfe/v2/registry/modules/"
"providers.v1" = "https://terrapod-internal.example.com/api/tfe/v2/registry/providers/"
}
}
That's terraform's remote service discovery mechanism, overridden via a CLI host{} block. It means:
- User code can refer to
source = "terrapod.example.com/myorg/aws-vpc/aws"— the canonical hostname they'd use from their laptop. - Humans on the network that can reach
terrapod.example.comnatively (e.g. on tailnet) hit it directly. No CLI config tweaks required. - Runners in remote clusters resolve the canonical hostname as a label but terraform routes the actual HTTPS requests to the internal URL the runner can actually reach.
When the two URLs are the same (single-network deployments), no redirect is generated — TP_PUBLIC_API_URL is silently omitted from the Job spec.
Pattern recipes
A. Tailscale management, internal LB for agents, Funnel for webhooks
ingress:
enabled: true
className: tailscale
hostname: terrapod.example.com # tailnet-only
tls: true
internalIngress:
enabled: true
className: traefik-internal # cluster-wide internal Traefik, TG-routed
hostname: terrapod-internal.example.com
tls: true
annotations:
cert-manager.io/cluster-issuer: lets-encrypt-prod
external-dns.alpha.kubernetes.io/hostname: terrapod-internal.example.com
webhookIngress:
enabled: true
className: tailscale # Tailscale Funnel
hostname: terrapod-webhooks.example.com
tls: true # Tailscale Funnel terminates TLS at the edge;
# `tls: true` here just lets the chart render
# the spec's TLS block — the operator-managed
# Tailscale layer handles the cert.
annotations:
tailscale.com/funnel: "true"
api:
config:
external_url: https://terrapod.example.com # canonical, used for self-referential URLs
listener:
apiUrl: https://terrapod-internal.example.com # what listener pods call
# publicApiUrl defaults to api.config.external_url — no need to set
Runners receive TP_API_URL=https://terrapod-internal.example.com and TP_PUBLIC_API_URL=https://terrapod.example.com, auto-generate the host redirect.
B. Internal LB for everyone (no public surface)
ingress:
enabled: true
className: internal-nginx
hostname: terrapod.internal.example.com
tls: true
# No webhookIngress — no public webhook source. (E.g. VCS poller is the only
# integration path, or webhooks come via a private network connector.)
# No internalIngress — listener.apiUrl can just use the primary ingress.
api:
config:
external_url: https://terrapod.internal.example.com
listener:
apiUrl: https://terrapod.internal.example.com
# publicApiUrl == apiUrl == external_url — runner sees no asymmetry, no host{} redirect.
C. Single public hostname (smallest deployment)
ingress:
enabled: true
className: nginx
hostname: terrapod.example.com
tls: true
api:
config:
external_url: https://terrapod.example.com
listener:
apiUrl: "" # defaults to in-cluster Service (same-release agents)
# publicApiUrl unset # nothing for the runner to redirect
Operational checks
After enabling internalIngress:
- Resource provisioned:
kubectl get ingress -n terrapodshows three Ingress objects (or two, if webhookIngress is off). - TLS cert issued: if using cert-manager,
kubectl get certificate -n terrapod terrapod-internal-tlsshowsReady=Truewithin ~30 s of the Ingress being created. - DNS record published: if using external-dns, the configured private zone should have an A/AAAA record matching
internalIngress.hostname. - Reachable from agent clusters:
kubectl run -n default --rm -it --restart=Never --image=curlimages/curl debug -- curl -sS https://terrapod-internal.example.com/.well-known/terraform.jsonfrom an agent cluster should return the service-discovery JSON document. This endpoint is unauthenticated and goes through the BFF the same way listener and runner traffic does, so a 200 here proves both the Ingress routing and BFF→API proxy work. - Listener using the internal URL:
kubectl get configmap -n terrapod <release>-runner-config -o jsonpath='{.data.runners\.yaml}' | grep server_urlshows the internal hostname (the listener readsserver_urlfromrunners.yaml, not a Deployment env var). - Runner Job spec carries
TP_PUBLIC_API_URL: trigger a plan, thenkubectl get job -n terrapod-runners <job> -o yaml | grep TP_PUBLIC_API_URLshould show the canonical URL. - Runner
terraform.rcincludes the host block:kubectl exec -n terrapod-runners <pod> -- cat /tmp/terraform.rc | grep -A1 'host "'shows thehost{}redirect the entrypoint wrote.
What this is not
- Not a multi-tenant networking model. Terrapod is single-organisation by design. The three Ingresses serve different audiences of the same Terrapod instance, not different tenants.
- Not authentication. Listener cert and runner-token auth apply on every endpoint regardless of which Ingress the request entered through. An attacker landing on the internal Ingress without a valid cert/token gets the same 401 as on the public one.
- Not zero-egress. The page title says air-gapped because that is what people search for, and the execution clusters genuinely can be sealed. The API is a different matter: it needs an outbound path to fill its pull-through caches and to reach whatever VCS, IdP and notification targets you have configured. Those can all be internal, and a forward proxy is supported — but "no outbound from the API at all" is not a configuration Terrapod supports. See Outbound — what the API itself reaches.
- Not a substitute for in-cluster reachability for same-cluster deployments. Where the API runs in the same cluster as the listener,
listener.apiUrl: ""(the chart's in-cluster Service default) is the right answer and no Ingress hop is needed.