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?

AudienceReachesAuthChart block
Humans (browser, CLI)UI, admin API, CLI cloud-block, SSO redirectSession cookie / Bearer API tokeningress (primary)
GitHub / GitLab / external run-task services/vcs-events, /task-stage-results/.../callbackHMAC-signed payloadwebhookIngress (optional)
Listener pods + runner Jobs from other clusters/agent-pools/join, /listeners/.../events, /runs/.../artifacts/..., registry downloadsX.509 listener cert / runner tokeninternalIngress (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 reachesWhenRemoving the internet dependency
terraform / tofu / terragrunt release hostsFilling the binary cachePoint the download base + version index at an internal mirror
OPA / Trivy / Checkov release hostsFilling the binary cache for the platform tools — OPA when a policy set applies, the scan engines when scanning is onPoint 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 registriesFilling the provider network mirrorPoint at an internal registry mirror
GitHub releases (Terrapod's own provider)Serving the platform providerInternal mirror override
The cost pricesheet objectCost estimation (on by default)Pre-seed the cached object, or set cost_estimation.prices_url to an internal copy
Your VCS hostPolling branches and pull requestsAlready internal if you self-host GitHub Enterprise or GitLab
Your identity providerSSO discovery / JWKSAlready internal if the IdP is
Slack, webhook and SMTP targetsNotifications, if configuredPoint at internal endpoints, or leave notifications off
External run-task servicesRun tasks, if configuredInternal services, or leave run tasks off
A model endpointThe AI layer, off by defaultSelf-hosted model, or leave it off
The peer nodeHA replication, if configuredA 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:

  • *.hostname must be set when *.enabled: true.
  • web.enabled: true is required (every Ingress routes to the web BFF).
  • webhookIngress.paths must be non-empty when webhookIngress.enabled (default ships a path allow-list).
  • internalIngress.paths must 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 valuerunners.yaml keyMeaning
listener.apiUrlserver_urlThe 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_urlThe 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.com natively (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:

  1. Resource provisioned: kubectl get ingress -n terrapod shows three Ingress objects (or two, if webhookIngress is off).
  2. TLS cert issued: if using cert-manager, kubectl get certificate -n terrapod terrapod-internal-tls shows Ready=True within ~30 s of the Ingress being created.
  3. DNS record published: if using external-dns, the configured private zone should have an A/AAAA record matching internalIngress.hostname.
  4. 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.json from 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.
  5. Listener using the internal URL: kubectl get configmap -n terrapod <release>-runner-config -o jsonpath='{.data.runners\.yaml}' | grep server_url shows the internal hostname (the listener reads server_url from runners.yaml, not a Deployment env var).
  6. Runner Job spec carries TP_PUBLIC_API_URL: trigger a plan, then kubectl get job -n terrapod-runners <job> -o yaml | grep TP_PUBLIC_API_URL should show the canonical URL.
  7. Runner terraform.rc includes the host block: kubectl exec -n terrapod-runners <pod> -- cat /tmp/terraform.rc | grep -A1 'host "' shows the host{} 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.