Runner Pods¶
Every run of a Terraform object happens in a dedicated Pod,
<name>-tf-runner, in the object's namespace. spec.runner controls what that
Pod looks like.
spec:
runner:
binary: auto # auto | terraform | tofu
image: "" # override the controller's default runner image
serviceAccountName: tf-runner # identity in the namespace (RBAC, workload identity)
alwaysCleanup: true # delete the Pod after each run
terminationGracePeriodSeconds: 30
podTemplate:
metadata:
labels: {}
annotations: {}
spec:
env: []
envFrom: []
volumes: []
volumeMounts: []
initContainers: []
nodeSelector: {}
affinity: {}
tolerations: []
hostAliases: []
priorityClassName: ""
securityContext: {} # replaces the hardened default when set
resources: {}
Lifecycle¶
- The controller creates the Pod when a run starts (
RunnerConnectionFailedafter--runner-creation-timeoutif it does not become Ready). - The source archive is uploaded over gRPC/mTLS; Terraform runs in
/tmp/<namespace>-<name>/<path>; plans, state and outputs are written to the Kubernetes API with the Pod's ServiceAccount. - With
alwaysCleanup: true(default) the Pod is deleted when the run ends, successfully or not. The next run starts a fresh Pod.
With alwaysCleanup: false the Pod stays between runs and is reused, which
saves the image pull and provider download on each reconcile (providers are
cached in TF_PLUGIN_CACHE_DIR=/tmp/plugin-cache inside the Pod). The
controller still replaces it when the TLS certificate rotates or when the Pod
has exited; a change to spec.runner (image, env, resources) only takes
effect on the next fresh Pod — delete the Pod by hand to force it. Prefer the
default unless
reconcile latency matters more than the Pod's footprint; a long-lived Pod also
keeps credentials in memory longer.
Pods carry labels app.kubernetes.io/name=tf-runner,
app.kubernetes.io/instance=tf-runner-<revision>,
infra.argocd.io/terraform=<namespace>, plus whatever podTemplate.metadata.labels
adds — handy for NetworkPolicies and cost allocation.
Image and binary¶
The default image comes from the controller (RUNNER_POD_IMAGE, pinned per
release) and contains both terraform and tofu. binary picks one per
object; auto prefers tofu. status.binary records what ran.
Use image for a derived image with extra tools (aws, gcloud, kubectl,
sops), pinned provider mirrors, or a single binary — see
Terraform vs OpenTofu › Custom runner images.
The image must keep the runner entrypoint, UID 65532, and
/home/runner + /tmp writable (they are emptyDir mounts).
Private registries: add imagePullSecrets to the tf-runner ServiceAccount
(Kubernetes applies them to every Pod using it).
Environment¶
The controller sets POD_NAME, POD_NAMESPACE and forwards its own
HTTP_PROXY/HTTPS_PROXY/NO_PROXY so runners behind the same egress proxy
work without configuration. Add your own with env/envFrom: provider
credentials, TF_LOG=DEBUG for verbose runner logs, TF_VAR_* (although
spec.vars is the preferred way), registry mirrors via TF_CLI_CONFIG_FILE
(see spec.cliConfigSecretRef).
spec:
runner:
podTemplate:
spec:
env:
- name: TF_LOG
value: INFO
- name: AWS_REGION
value: eu-west-1
envFrom:
- secretRef:
name: aws-creds
Resources and scheduling¶
No requests or limits are set by default. Terraform's footprint is
provider-dependent — the AWS and Google providers can use several hundred MiB;
large states and -parallelism push it further. Set them explicitly for
production stacks, or rely on a namespace LimitRange:
spec:
runner:
podTemplate:
spec:
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { memory: 2Gi }
nodeSelector:
workload: infra
tolerations:
- key: infra
operator: Exists
priorityClassName: infra-runs
If a ResourceQuota rejects the Pod, the object reports
RunnerQuotaExhausted and retries; see
Troubleshooting.
Volumes and init containers¶
Mount what the configuration needs — a CA bundle, a kubeconfig for a remote cluster used by the Kubernetes provider, a pre-populated provider mirror — and prepare it with init containers:
spec:
runner:
podTemplate:
spec:
volumes:
- name: corp-ca
configMap: { name: corp-ca-bundle }
- name: providers
emptyDir: {}
volumeMounts:
- name: corp-ca
mountPath: /etc/ssl/certs/corp-ca.crt
subPath: ca.crt
readOnly: true
- name: providers
mountPath: /mirror
initContainers:
- name: fetch-providers
image: registry.example.com/tools/oras:latest
command: [oras, pull, registry.example.com/tf/providers:2026.10, -o, /mirror]
volumeMounts: [{ name: providers, mountPath: /mirror }]
For small files from Secrets, spec.fileMappings is simpler than a volume
(see Variables and credentials).
Security context¶
The default is hardened: UID 65532, non-root, read-only root filesystem, all
capabilities dropped, no privilege escalation, RuntimeDefault seccomp. This
is the container security context; setting podTemplate.spec.securityContext
replaces the whole block, so restate the restrictive fields when you
change one:
spec:
runner:
podTemplate:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
seccompProfile: { type: RuntimeDefault }
There is no Pod-level security context (fsGroup, supplementalGroups).
Projected ServiceAccount tokens (workload identity) are still readable because
the kubelet assigns projected files to the containers' common runAsUser;
if you add initContainers, set runAsUser: 65532 on them as well so that
rule keeps applying.
Pod Security Admission restricted is satisfied by the defaults; policy
engines that mutate Pods (adding sidecars, labels) are fine as long as the
runner container itself and the TLS volume are untouched.
Networking¶
The controller connects to the runner on port 30000 by Pod IP; the runner
needs egress to the Kubernetes API, to provider APIs, to the Terraform
registry (or your mirror) and to the source of any remote modules. The runner
does not need to reach Git — the controller fetches sources. A
NetworkPolicy for a Terraform namespace therefore typically allows: ingress
30000/tcp from the controller namespace, egress to the API server, DNS and
0.0.0.0/0:443 (or your proxy).