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.

Local Kubernetes Inner Loop Flow from a source edit through image build, load into the cluster and pod update to the browser. Local Kubernetes Inner Loop edit source host editor build image docker buildx load or push kind load pod updates rollout or sync browser forward, ingress
Tooling choices change how each step is done, not the order of the steps.

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-versions or mise.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"
  1. Save the file as k8s/kind.yaml and pin the node image to the same minor version as production.
  2. Create the cluster with kind create cluster --config k8s/kind.yaml.
  3. 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.

Ad-Hoc Cluster vs Config-File Cluster Comparison of creating kind clusters by hand against creating them from a checked-in config file. Ad-Hoc Cluster vs Config-File Cluster kind create cluster kind create --config version from kind release node image pinned no ingress ports 80 and 443 mapped no registry wiring registry patch included differs per laptop identical everywhere
The config file turns the cluster into reproducible, reviewable infrastructure.

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.

Load Directly or Use a Local Registry? Decision diagram choosing between kind load and a local registry based on image size and cluster shape. Load Directly or Use a Local Registry? Image over 500 MB or multi-node? No kind load docker-image Yes local registry at 5001
Small images on one node load fine; large images or several nodes favour a registry.

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'])
  1. Save this as Tiltfile at the repository root.
  2. Run tilt up; the web UI at http://localhost:10350 shows each resource's build and runtime logs.
  3. Edit a file under api/src and 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.

Seconds From Save to Running Code Bar chart comparing time from file save to updated code running under three workflows. Seconds From Save to Running Code build, load, rollout 38 s Tilt image rebuild 14 s Tilt live_update 2 s Compose bind mount 1 s
Measured on a Node API; live update skips the image build for source-only changes.

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.

Port-Forward vs Ingress Locally Table comparing kubectl port-forward and an ingress controller on setup, stability, hostnames and TLS. Port-Forward vs Ingress Locally Aspect port-forward ingress Setup none controller install Survives pod restart no yes Hostnames localhost:port app.localhost TLS locally no yes, with mkcert
Port-forward for debugging one service; ingress for a browser-facing multi-service app.

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.1 in 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. Raise fs.inotify.max_user_instances to 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.

Every guide in this topic