Go Container Images and Kubernetes Deployments with .ko.yaml
The problem
For a Go application, the container often involves surprisingly much manual work: maintaining a Dockerfile, configuring cross-compilation, choosing a runtime image, assigning tags, pushing images, and updating the reference in the Kubernetes manifest.
That is manageable for one application. In CI pipelines and repositories with several small Go services, it quickly turns into repeated glue code. This is where ko fits in: instead of starting from a Dockerfile, ko uses the Go import path as its central piece of information and builds a container image directly from it.
That approach works particularly well for Go because a compiled binary and a small, preferably empty runtime image are often all a container needs.
.ko.yaml as build configuration
The build configuration lives in .ko.yaml in the project directory. This is the configuration used by my portfolio:
defaultBaseImage: gcr.io/distroless/static-debian12:nonroot
defaultPlatforms:
- linux/amd64
builds:
- id: portfolio
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
Each setting has a specific purpose:
defaultBaseImageselects the runtime image. The distroless image contains no shell and very few unnecessary userspace tools, and runs as a non-privileged user.defaultPlatformslimits this build tolinux/amd64. ARM deployments can use multiple entries, such aslinux/amd64andlinux/arm64.buildsdescribes the Go builds in the project. One configuration can contain multiple binaries with differentidvalues.main: .points to the Go package in the project directory. It could just as well point to a subpath such as./cmd/api.CGO_ENABLED=0produces a statically linked binary that fits a static runtime image.-trimpathremoves local filesystem paths from the binary. This improves build reproducibility and reveals less about the build environment.-s -wreduces the binary size by removing symbol and debug information.
Build-specific values override matching global defaults. That is useful when several Go binaries in the same repository need different entry points or build tags.
Building and pushing an image
For a registry build, a Go package or local package path is enough:
export KO_DOCKER_REPO=registry.example.com/portfolio
ko build .
ko compiles the binary, builds the container image, pushes it to the configured registry, and prints a complete image reference with a digest. The digest is more useful than a freely chosen, mutable tag: it identifies the exact content that was built and published.
Authentication can use the existing container registry configuration. In CI, KO_DOCKER_REPO is typically provided by the pipeline environment, while the registry is authenticated through the CI system's usual mechanism.
For local experiments, ko can also resolve against the local Docker daemon:
ko resolve --local -f k8s/
The ko:// references are resolved under ko.local/.... For a real cluster, an accessible registry is usually the more robust option.
Kubernetes knows the Go import path
Traditionally, a Deployment contains a concrete image reference:
apiVersion: apps/v1
kind: Deployment
metadata:
name: portfolio
spec:
selector:
matchLabels:
app: portfolio
template:
metadata:
labels:
app: portfolio
spec:
containers:
- name: portfolio
image: registry.example.com/portfolio:v1.2.3
With ko, it becomes a reference to the Go package:
apiVersion: apps/v1
kind: Deployment
metadata:
name: portfolio
spec:
selector:
matchLabels:
app: portfolio
template:
metadata:
labels:
app: portfolio
spec:
containers:
- name: portfolio
image: ko://github.com/srkn0/main
The ko:// scheme is not an image Kubernetes can pull by itself. It is an instruction to ko: build the Go package, publish the image, and replace the reference in the YAML with the concrete registry address and digest.
Two ways to deploy
When I want to inspect the rendered manifest or hand it to a GitOps pipeline, I resolve the references separately:
ko resolve -f k8s/ > rendered.yaml
kubectl apply -f rendered.yaml
For the direct path, ko performs both steps:
ko apply -f k8s/
That is the particularly nice part of the integration. The YAML still describes Kubernetes resources, but the image selection stays where it belongs: with the Go import path. There is no manually maintained image tag to update between build and deployment.
The references generated by ko are content-addressed as well. When the code changes, a different digest is produced. The Deployment therefore points unambiguously to the image belonging to that rollout.
The same configuration can be reused to remove those resources:
ko delete -f k8s/
Why .ko.yaml works so well for Go
The configuration is small, but it connects several responsibilities that are often kept separate:
- The Go package and entry point define what is built.
.ko.yamldefines reproducible compiler and image parameters.ko buildcreates and publishes the image.ko://makes the same import path reusable in Kubernetes YAML.ko applydelivers the result to the cluster.
This keeps a Deployment manifest declarative without leaving it with an outdated, manually maintained image tag. At the same time, the application remains a normal Go project: no Dockerfile, no custom multi-stage build, and no separate image build system.
This is not a replacement for every container pipeline. If an image needs native libraries, multiple processes, or complex build steps, a conventional Dockerfile or BuildKit workflow may be the better fit. For static Go services, small operators, and Kubernetes controllers, however, ko is a remarkably direct path from main to a running Deployment.