Docker, K8s, and Terraform: The Go Ops Triad That Actually Ships
The three tools every Go deploy runs on are all written in Go: Docker builds the image, Kubernetes runs the image, Terraform provisions the infrastructure the image runs on. That is the whole ops pipeline — build → run → provision — and the reason it is boring is that each step is a Go binary with a stable CLI and a well-defined exit code. The tools that do not ship (Ansible, Chef, Puppet, Salt) are all config-push frameworks: agent-based, stateful, and one more thing to keep alive. The triad is pull-based, declarative, and stateless — which is exactly what Go’s go build culture likes.
This post is the ~30-line Go orchestrator that runs the whole triad, plus the three gotchas that actually kill it: the latest-tag race, the unbounded rollout, and the deploy-before-provision ordering trap. By the end your deploy pipeline is one go run, and you know which of the three failure modes you have hit when a deploy goes sideways.
The pipeline: build → run → provision
Each leg is a separate Go binary; the only shared state between them is a registry tag and a cluster name. Nothing in the triad requires an agent on the node — Docker talks to a registry, kubectl talks to the API server, terraform talks to the provider API. That agentless property is why the triad survives a node loss: the new node pulls the image and joins the cluster, no bootstrapping daemon needed.
sequenceDiagram
participant M as go run (orchestrator)
participant TF as terraform
participant D as docker
participant K as kubectl
M-->>TF: apply -chdir=infra
TF--->>M: exit 0 (state file written)
M-->>D: build -t golang4u: .
D-->>D: push golang4u:
D--->>M: exit 0
M-->>K: set image deployment/golang4u golang4u=golang4u:
K-->>K: rollout status --timeout=60s
K--->>M: exit 0 (rollout done)
stateDiagram-v2
[*] --> Provisioning: terraform apply
Provisioning --> Building: terraform ok
Building --> Pushed: docker build + push
Pushed --> RollingOut: kubectl set image
RollingOut --> Running: rollout status (60s)
RollingOut --> Failed: timeout / crash loop
Failed --> RollingOut: next run (new sha)
Running --> [*]
The code: the triad as a 30-line Go program
The pipeline is three go binaries in a row — docker, kubectl, terraform — and the orchestrator of those three commands is itself just a go binary. That is the post in one line: the whole ops pipeline is Go calling Go. The program below is the whole triad: compute the tag once, run the three legs in order, and make every leg bounded.
| |
| |
Thirty lines, three commands, one program. (A Makefile with the same three targets is a one-for-one translation of this file; the Go form is the point — the orchestrator is a Go binary, the same language as everything else in the pipeline.)
The three gotchas that kill it
- The
latesttag is a pull race. Two deploys at once build two different images, both taggedlatest, and the registry serves whichever was pushed second — so a deterministic tag (git rev-parse --short HEAD) is the only tag that is. Every image in the triad is content-addressed by its commit. rollout statuswithout--timeouthangs the whole pipeline. A pod that fails its readiness check keeps the rollout open forever; without the 60s cap, the deploy blocks, CI times out, and nobody can tell which leg failed. The timeout is the failure detector; the triad has no other health signal.- Deploy before provision = Pending pods.
kubectl rollout statuscounts replicas, not ready nodes. A pod on an unprovisioned node isPendinguntil terraform creates the capacity, so the orchestrator runsprovisionfirst. Reordering the two is the single most common cause of “the deploy succeeded and the cluster is broken.”
Provisioning and deploys: the state model
| Concern | Who owns the state | What breaks when the triad is mis-ordered |
|---|---|---|
| Image identity | Docker registry, by git SHA tag | Two latest builds → nondeterministic deploys |
| Cluster shape (nodes, net) | Terraform state file (infra/terraform.tfstate) | Deploy lands before nodes exist → pods Pending |
| Desired version per app | Kubernetes Deployment spec | set image with a stale tag → old image re-rolled |
| Rollout health | rollout status --timeout | No timeout → pipeline hangs on a stuck pod |
The separation is the design: Terraform owns infrastructure, Kubernetes owns workloads, Docker owns artifacts. When two of the three disagree (a tag in the registry that K8s points at, a node in Terraform’s state that K8s has not scheduled onto), the deploy is broken, not failing — nothing errors, it just does not come up. That is why the order is enforced in all, not left to the operator.
Note: keep
terraform -chdir=infrapointed at the sameinfra/directory in CI and locally. The state file is the source of truth; if CI provisions from one copy ofinfra/and you deploy from another, the two states drift and the “it works in my terminal” class of incident begins.
Warning:
kubectl set imagemutates the liveDeploymentspec out of band. If you ever switch the manifest to a versioned file (kubectl apply / kustomize), the hand-edited spec and the file will disagree on the nextapply, and the file wins — silently rolling back your image. Pick one source of truth for the image tag: theset imagecommand, or a template that injects$(TAG).
Takeaways
- The triad is pull-based and agentless: Docker pulls from a registry, K8s schedules from an image ref, Terraform applies against a provider API. Nothing to keep alive on the node.
- One tag, computed once: the git short SHA is the only deterministic identifier across all three legs.
latestis a race. - Every leg must be bounded:
--timeout=60son the rollout, a plan-check beforeapply -auto-approve. An unbounded leg hangs the whole pipeline. - Order is enforced, not assumed: provision → build → deploy, one line at a time. A deploy on unprovisioned nodes is
Pending, notFailed. - The orchestrator is the only state — it is the one place that knows the three commands and their order.
Next steps
- Add
terraform plan -no-color | tee plan.outbeforeapplyin CI: a zero-diff plan is a no-op deploy; diff the file to catch the “who changed infra out of band” incident class. - Add an image-pull smoke test after
deploy:curlthe readiness endpoint of the new pod. The rollout only proves a replica exists, not that it serves. - Keep
infra/, theDockerfile, and the orchestrator in one repo with the Go code. The triad is only boring when the build, the manifest, and the provision live where the code lives.