Local Kubernetes Development With kind and Tilt
Docker Compose covers most local development, but some teams ship to Kubernetes and depend on things Compose cannot express: Helm charts with templated configuration, ingress rules, init containers, ConfigMaps mounted as files, network policies, operators and custom resources. When the local environment is Compose and production is Kubernetes, every one of those becomes a class of bug that is only discovered after deploy — the chart renders a wrong value, a probe is misconfigured, an env var comes from a Secret that does not exist in the namespace. This topic, part of containerized local environments with Docker Compose, shows how to run a small, disposable Kubernetes cluster on a laptop, get locally built images into it quickly, and keep the inner loop — edit, rebuild, see the change — close to the speed developers are used to with Compose.
The first question is whether you need it at all. A local cluster costs memory, startup time and conceptual overhead for every developer. It earns its place when the Kubernetes manifests themselves are under active development, when services depend on cluster behaviour (service discovery through DNS names with namespaces, sidecars, admission webhooks), or when the team runs an operator whose behaviour must be tested. It does not earn its place when the only reason is "production uses Kubernetes" while the manifests rarely change; for that, Compose plus a CI job that deploys the chart to an ephemeral cluster is usually a better trade.
Where it is worth it, the pieces fit together in a consistent way regardless of which tools you pick. A cluster runs inside Docker containers on the laptop. Images built locally are loaded into that cluster's nodes, either directly or through a small local registry. A tool watches the source tree and rebuilds or syncs files into running pods. And a way of reaching services from the host — port-forwarding or an ingress controller — makes the application usable in a browser. The sections below walk through each.
Prerequisites
- Docker Engine 24+ with at least 6 GiB of VM memory for a single-node cluster plus a modest application; 8 GiB is more comfortable. See tuning Docker Desktop memory and CPU limits.
- kubectl matching the cluster version within one minor release (
kubectl version --client). - kind 0.24+ or k3d 5.7+, and Tilt 0.33+ if you use it for the inner loop.
- Helm 3.14+ if the application is packaged as a chart.
- Pinned tool versions in
.tool-versionsormise.toml, so every developer runs the same kind, kubectl and Tilt. Version drift between kubectl and the cluster is one of the most common sources of confusing errors, and the toolchain version management topic covers pinning.
A preflight check keeps the prerequisites honest:
#!/usr/bin/env bash
set -euo pipefail
for t in docker kubectl kind helm tilt; do
command -v "$t" >/dev/null || { echo "missing: $t"; exit 1; }
done
mem_gib=$(( $(docker info --format '{{.MemTotal}}') / 1024 / 1024 / 1024 ))
[ "$mem_gib" -ge 6 ] || { echo "docker VM has ${mem_gib}GiB; need 6+"; exit 1; }
kubectl version --client --output=yaml | grep gitVersion
kind version
Creating a reproducible cluster from a config file
Clusters created with bare kind create cluster differ between developers: different Kubernetes versions depending on the kind release, no ingress ports, no registry. Put the cluster definition in the repository so every cluster is identical and can be destroyed and recreated in under a minute:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: shop
nodes:
- role: control-plane
image: kindest/node:v1.30.4
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
listenAddress: "127.0.0.1"
- containerPort: 443
hostPort: 443
listenAddress: "127.0.0.1"
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
- Save the file as
k8s/kind.yamland pin the node image to the same minor version as production. - Create the cluster with
kind create cluster --config k8s/kind.yaml. - Verify the version matches production — this is the drift diagnostic for the section.
#!/usr/bin/env bash
set -euo pipefail
kind create cluster --config k8s/kind.yaml
kubectl --context kind-shop version --output=json | jq -r '.serverVersion.gitVersion'
kubectl --context kind-shop get nodes -o wide
Pinning the node image matters more than it looks. Kubernetes deprecates and removes API versions on a schedule; a manifest using a removed API applies cleanly on an older local cluster and fails in production, or the reverse. Matching the minor version locally makes those failures appear on the laptop. The kind with a local registry guide completes the containerdConfigPatches part of this file.
Getting local images into the cluster
A pod in kind cannot see images in the host's Docker image store; the kind node runs its own containerd. There are two ways across: kind load docker-image, which copies the image tarball into every node, and a local registry that the nodes pull from. Loading is simple and needs nothing extra, but it copies the whole image each time, which gets slow for large images and multi-node clusters. A registry only transfers changed layers and works the same way production does — the pod spec references an image by registry and tag.
#!/usr/bin/env bash
set -euo pipefail
docker build -t shop/api:dev ./api
kind load docker-image shop/api:dev --name shop
kubectl --context kind-shop set image deployment/api api=shop/api:dev
kubectl --context kind-shop rollout status deployment/api --timeout=90s
Two details trip people up. First, imagePullPolicy must not be Always for loaded images, or the kubelet tries to pull shop/api:dev from Docker Hub and fails with ErrImagePull; IfNotPresent is correct. Second, re-loading an image under the same tag does not restart pods — Kubernetes sees no change in the pod spec. Use a unique tag per build (a content hash or timestamp) or run kubectl rollout restart. The guide to loading images without a registry covers both fixes and the architecture mismatch that produces exec format error on Apple Silicon.
A fast inner loop with Tilt
Building an image and rolling out a deployment takes tens of seconds even when everything is cached. For interpreted languages and frontend code, that is far slower than the sub-second hot reload developers get with Compose bind mounts. Tilt closes most of that gap. It watches the source tree, rebuilds images when needed, and — with live_update — syncs changed files straight into running containers and runs a command there, skipping the image build entirely for code changes:
docker_build(
'shop/api',
'./api',
live_update=[
fall_back_on(['./api/package.json', './api/package-lock.json']),
sync('./api/src', '/app/src'),
run('kill -HUP 1', trigger=['./api/src/config']),
],
)
k8s_yaml(helm('./charts/shop', values=['./charts/shop/values-local.yaml']))
k8s_resource('api', port_forwards='8080:8080', labels=['backend'])
k8s_resource('web', port_forwards='5173:5173', labels=['frontend'])
- Save this as
Tiltfileat the repository root. - Run
tilt up; the web UI athttp://localhost:10350shows each resource's build and runtime logs. - Edit a file under
api/srcand watch the sync happen without a rebuild.
fall_back_on forces a full image rebuild when dependency manifests change, because syncing package.json alone would leave node_modules out of date. The Tilt live-reload guide covers compiled languages, where the sync target is a rebuilt binary rather than source files, and the drift check that confirms what is running matches the working tree.
Live update changes a running container in place, which has one consequence worth making explicit to the team: the pod no longer matches its image. That is fine for development, but it means a pod that has been live-updated for an hour can behave differently from a freshly built one — a file deleted locally may still exist in the container, and a dependency added without touching the manifest will be missing after a real rebuild. Tilt mitigates this by rebuilding whenever a fall_back_on path changes, and a periodic tilt down && tilt up, or simply the full rebuild that happens when a developer switches branches, resets the drift. Treat any "works in Tilt, fails in CI" report as a prompt to rebuild the image from scratch before debugging the code.
Resource configuration in the Tiltfile is also the natural place to encode the team's local conventions: which services are grouped under which labels in the UI, which ones start automatically and which only on demand (auto_init=False), and which port-forwards each developer can rely on. Keeping that in the repository means the Tilt UI looks the same on every laptop, which makes pairing and support much easier.
Reaching services from the host
Services inside the cluster are not reachable from the host by default. kubectl port-forward is the zero-configuration option: it tunnels one local port to one pod or service. It is ideal for a debugging session and for Tilt, which manages forwards automatically. For browser-facing applications with several services, cookies across subdomains, or OAuth redirects, an ingress controller behind the ports mapped in the kind config gives each service a hostname — the same pattern as the Compose reverse proxy, but with Kubernetes Ingress objects:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
spec:
ingressClassName: nginx
rules:
- host: api.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
- host: app.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 5173
Install the controller with the kind-specific manifest (kubectl apply -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml), wait for it to be ready, then apply the Ingress. *.localhost names resolve to loopback in browsers, so no hosts-file edits are needed, exactly as described in using .localhost subdomains. The port-forward versus ingress comparison covers when each is appropriate and why port-forwards drop during pod restarts.
Choosing between kind and k3d and minikube
All three create conformant Kubernetes clusters on a laptop. kind runs each node as a Docker container and is the standard for testing Kubernetes itself and for CI, which makes local and CI clusters identical. k3d runs k3s — a lighter distribution with a built-in Traefik ingress and local-path storage — in Docker, and starts faster with lower memory. minikube supports many drivers including full VMs, and ships add-ons for common components. For a team whose CI already uses kind, kind locally is usually the right answer; for teams optimising for laptop resources, k3d is attractive. The kind vs k3d vs minikube comparison has measurements.
Whatever the choice, script cluster lifecycle into the repository so creating, resetting and deleting a cluster are single commands:
#!/usr/bin/env bash
set -euo pipefail
case "${1:-up}" in
up) kind get clusters | grep -qx shop || kind create cluster --config k8s/kind.yaml ;;
reset) kind delete cluster --name shop && kind create cluster --config k8s/kind.yaml ;;
down) kind delete cluster --name shop ;;
esac
kubectl config use-context kind-shop >/dev/null 2>&1 || true
kubectl get nodes 2>/dev/null || echo "cluster removed"
Platform caveats
Apple Silicon (ARM64): kind and k3d node images are multi-arch, but application images built on an M-series Mac are arm64. That is correct for the local cluster; the mistake is pushing those same tags to a registry used by amd64 production nodes. Tag local images distinctly (
:dev) and build release images in CI.
macOS (Docker Desktop): Docker Desktop's built-in Kubernetes is convenient but shares the Docker VM, cannot be version-pinned per project, and is harder to reset cleanly. Prefer kind or k3d for team setups.
WSL2: kind works inside WSL2 with Docker Desktop's WSL integration or Docker Engine in the distribution. Port mappings bound to
127.0.0.1in the kind config are reachable from Windows browsers through WSL's localhost forwarding.
Linux (inotify limits): clusters with many pods exhaust default inotify instances and fail with
too many open files. Raisefs.inotify.max_user_instancesto 512, as described in fixing ENOSPC file watcher limits.
Rollback and recovery
A local cluster should be disposable. When it gets into a strange state — stuck finalizers, a broken CNI after sleep, certificates expired after months without restart — delete and recreate it rather than debugging it. Keep anything that must survive in the repository (manifests, charts, seed jobs) and nothing in the cluster that cannot be recreated in a minute.
#!/usr/bin/env bash
set -euo pipefail
kind delete cluster --name shop
kind create cluster --config k8s/kind.yaml
tilt up --stream=false &
sleep 5
tilt wait --for=condition=Ready uiresource/api --timeout=300s
To abandon the local cluster entirely and go back to Compose for day-to-day work, delete the cluster, remove the kube context and keep the manifests for the CI deployment job — no application changes are needed if configuration arrives through environment variables in both worlds.
Frequently Asked Questions
Do we need a local Kubernetes cluster if production runs on Kubernetes?
Not necessarily. If the manifests rarely change, Compose for daily work plus a CI job that deploys the chart to an ephemeral cluster catches most issues with less overhead. A local cluster pays off when manifests, charts or operators are under active development.
Why does my pod show ErrImagePull for an image I just loaded?
The pod's imagePullPolicy is Always, or the tag is latest (which defaults to Always), so the kubelet tries to pull from a registry instead of using the loaded image. Use a specific tag and imagePullPolicy: IfNotPresent.
How much memory does a local cluster need?
A single-node kind or k3d cluster uses about 600–900 MB for the control plane before any workloads. Add your application's usage and ingress controller; 6 GiB of Docker VM memory is a practical minimum for a small multi-service app.
Is Tilt required?
No. Skaffold and plain scripts can do the same job. Tilt's advantages are live_update for fast file syncs, a web UI that shows every resource's status, and a Starlark config that is easy to extend; Skaffold is a good alternative if the team already uses it for CI.
Related
- Create a kind cluster wired to a local registry
- Live-update pods on every save with Tilt
- Build images for both arm64 and amd64
- Keep CI and local clusters on the same runner image
Every guide in this topic
- Creating a kind Cluster With a Local RegistryRun a registry container next to kind so pods pull localhost:5001 images: containerd hosts.toml, the registry network, and fixing ErrImagePull and http errors.
- kind vs k3d vs minikube for Local DevelopmentChoose a local Kubernetes distribution for a team: kind, k3d and minikube compared on startup time, memory, CI parity, ingress, storage and multi-node support.
- Live-Reloading Kubernetes Workloads With TiltCut the edit-to-running-pod loop from 40 seconds to 2 with Tilt live_update: sync rules, fall_back_on for dependency changes, compiled languages and drift checks.
- Loading Local Images Into kind Without a RegistryUse kind load docker-image correctly: fix ErrImagePull from imagePullPolicy, pods that keep old code after a reload, and exec format errors from the wrong arch.
- Port-Forwarding vs Ingress for Local KubernetesDecide how to reach services in a local cluster: kubectl port-forward that drops on pod restart versus an ingress with .localhost hostnames, TLS and stable URLs.