Most Kubernetes teams debug their platform in the one place it does not run: their laptop. Production is 82% likely to be Kubernetes (CNCF Annual Cloud Native Survey, 2025, up from 66% in 2023), yet the local dev loop is often a Docker Compose file that quietly disagrees with the real cluster on namespaces, RBAC, and service discovery. We rebuilt our inner loop around a real local Kubernetes cluster driven by Tilt, and it changed how fast — and how honestly — we could iterate on a multi-service backend. This is the setup, the pattern, and the one bug it caught that a mock never would have.

Key takeaways

  • Run the actual deployment target locally: a real Kubernetes cluster on Kind reproduces namespaces, RBAC, and service discovery that Docker Compose only approximates.
  • Tilt’s Live Update syncs code into a running container “in seconds, not minutes” (tilt.dev, 2026), which is what makes a heavier local substrate viable.
  • Ship one self-contained Tiltfile per service that runs standalone or composes into a root Tiltfile, so a new contributor can spin up just the service they touch.
  • Test auth against the real identity stack with curl, not a mock — it caught a client-config bug that mocked auth would have hidden.
  • Skip Bitnami: its free stable images were deprecated in 2025, so pinning to them is silent rot.

Why run a real cluster locally instead of Docker Compose?

Because the platform you ship to is Kubernetes, and Kubernetes has surfaces Docker Compose cannot fake. Namespaces, RBAC bindings, service accounts, DNS-based service discovery, and admission control are all first-class in a cluster and simply absent in Compose. When they break, a Compose-based local loop stays green and the bug surfaces only after you deploy — the most expensive place to find it.

Kubernetes is now the default production substrate: the CNCF (2025) puts production use at 82% and cloud-native adoption at 98%. If that is your target, local-vs-prod parity is not a luxury; it is the difference between a fast inner loop and a slow, deploy-driven outer loop. The same platform-engineering discipline that standardises production is worth applying to the laptop.

The honest tradeoff: a real cluster is heavier than docker compose up. You need a fast local cluster and a tool that keeps the edit-run cycle tight despite the extra substrate. That is exactly the gap Kind and Tilt fill.

What is Tilt, and how does it fix the Kubernetes inner loop?

Tilt is an open-source toolkit “for fixing the pains of microservice development” (tilt-dev/tilt, 9.9k GitHub stars, v0.37.5, 2026). It watches your files, rebuilds container images, and brings your cluster up to date automatically — turning a manual build-push-deploy sequence into a single tilt up that stays live as you edit.

The feature that earns its keep is Live Update. Instead of rebuilding an image and rolling out a new pod on every save, Live Update syncs the changed files straight into the running container and re-runs the build step in place. Tilt’s own framing is deploying code to running containers “in seconds not minutes” (tilt.dev, 2026). For a compiled language like Go, that means the .go files land in the pod and the binary recompiles there — no image round-trip.

The Tiltfile itself is written in Starlark, a Python dialect, so it has real functions, loops, and conditionals rather than YAML templating. That matters once your local environment has more than one service and a few shared dependencies to wire together.

How do you bootstrap a local cluster with ctlptl and Kind?

Start with a cluster that creates itself. We use Kind — “local Kubernetes clusters using Docker container nodes” (kubernetes-sigs/kind, 15.4k stars, a CNCF-certified conformant installer, 2026) — fronted by ctlptl to attach a local image registry. A small helper script makes the whole thing idempotent, creating the cluster only if one is not already running:

#!/usr/bin/env bash
# scripts/ensure-cluster.sh — idempotent local cluster + registry
set -euo pipefail

CLUSTER_NAME="klocal"

if ctlptl get cluster "kind-${CLUSTER_NAME}" >/dev/null 2>&1; then
  echo "cluster kind-${CLUSTER_NAME} already up — reusing"
  exit 0
fi

# ctlptl creates the Kind cluster AND a local registry Tilt can push to
ctlptl create registry ctlptl-registry --port=5005
ctlptl create cluster kind --name="${CLUSTER_NAME}" --registry=ctlptl-registry

Wiring this in as a Tilt local_resource means a contributor runs tilt up and gets a cluster, a registry, and their service — no README full of prerequisite commands to run by hand first.

Gotcha: Let ctlptl own both the cluster and the registry. If you create a Kind cluster without a registry Tilt can push to, every build falls back to kind load, which is far slower than a pushed image and defeats the point of a tight loop.

What makes one Tiltfile per service the right pattern?

Each service ships its own self-contained Tiltfile, designed to run standalone or be folded into a root Tiltfile later via Tilt’s include(). This is the reusable core of the whole setup, and it rests on three rules.

First, path independence: every path in the Tiltfile is resolved relative to the Tiltfile’s own directory, so it behaves identically whether run from the service root or included from a parent. Second, a dependency toggle: a boolean flag groups shared infrastructure — Postgres, an identity stack, a gateway — so a future root Tiltfile can provide them once instead of each service starting its own copy. Third, standalone-first: the default tilt up in any service directory brings up a working environment for that service alone.

# Tiltfile — self-contained, includable, path-independent
config.define_bool('skip_deps')  # a root Tiltfile sets this to own shared deps
cfg = config.parse()

# ensure a cluster exists before anything else
local_resource('cluster', cmd='./scripts/ensure-cluster.sh',
               labels=['00-bootstrap'])

if not cfg.get('skip_deps', False):
    k8s_yaml('deploy/postgres.dev.yaml')     # plain upstream YAML, not Bitnami
    k8s_yaml('deploy/ory.dev.yaml')          # identity stack for local auth

docker_build('local/klastro-api', '.',
             dockerfile='Dockerfile.dev')
k8s_yaml('deploy/api.dev.yaml')

The payoff is onboarding: a new contributor touching one service clones it, runs tilt up, and is productive without standing up the entire platform. When they need the full stack, a root Tiltfile include()s each child and sets skip_deps=True so shared dependencies come up exactly once. It mirrors the structure of a submodule monorepo without coupling the services to each other.

How does Live Update keep the Go loop fast?

By syncing source and recompiling in the pod instead of rebuilding the image. The docker_build step declares a live_update block that copies changed .go files into the container and runs the compile there, so a save-to-running-binary cycle drops from a full image build to a few seconds.

docker_build(
    'local/klastro-api', '.',
    dockerfile='Dockerfile.dev',
    live_update=[
        sync('.', '/app'),                      # push changed source into the pod
        run('go build -o /app/server ./cmd/api',# recompile in place
            trigger=['./**/*.go']),
    ],
)

# Forward Delve so you can attach a debugger to the in-cluster process
k8s_resource('klastro-api', port_forwards=['8080:8080', '2345:2345'])

Port-forwarding Delve on 2345 means you attach your IDE’s debugger to a process running inside the cluster, against real service DNS and real config — not a locally-linked binary that skips half the platform. You get breakpoints on the code path that will actually run in production. When the failure you are chasing is a non-deterministic one — the kind we hunt in controller cache staleness — debugging against the real substrate is the difference between reproducing it and guessing.

Why avoid Bitnami images and charts?

Because the free, stable Bitnami catalog is going away. Effective August 28, 2025, Bitnami moved its versioned Docker Hub images to a bitnamilegacy repository that receives no further updates, fixes, or support, with the public catalog narrowed to a latest-only community subset (bitnami/charts #35164, 2025). The deletion of the old catalog was postponed to late September 2025 to give teams time to migrate (Northflank, 2025).

For a local environment, pinning Postgres or an identity stack to a Bitnami tag now means one of two failure modes: a frozen legacy image that silently stops getting patches, or a “latest-only” image that shifts under you between rebuilds. Neither is reproducible. We deploy shared dependencies from plain upstream YAML or the projects’ own charts instead, which keeps the local stack pinnable and free of a subscription dependency.

Gotcha: Bitnami charts and images were the path of least resistance for years, so they are baked into countless tutorials and starter repos. Audit your local deploy/ manifests for docker.io/bitnami/* references before they turn into a legacy image that never updates again.

How do you test auth flows before a UI exists?

With curl against the real identity stack — and it is worth doing precisely because it catches bugs a mock cannot. Before any frontend existed, we exercised protected API endpoints by minting a real session token from the identity provider and passing it as an X-Session-Token header through the gateway, straight to the API:

# 1. Mint a real session token from the identity stack (Ory Kratos)
SESSION_TOKEN=$(curl -s -X POST \
  "http://localhost:4433/self-service/login/api" \
  -H 'Content-Type: application/json' \
  -d '{"method":"password","identifier":"[email protected]","password":"…"}' \
  | jq -r '.session_token')     # ory_st_…

# 2. Hit a protected endpoint THROUGH the gateway with that token
curl -s "http://localhost:8080/v1/orgs" \
  -H "X-Session-Token: ${SESSION_TOKEN}" | jq

Running this against the real authorization stack rather than a stub surfaced a genuine integration bug: the permission-check client library was resolving the authorization service’s address as though the host:port needed a URL scheme, and failing the lookup. A mocked-auth local setup would have returned a cheerful 200 and hidden the defect until it hit a shared environment. Testing the real chain — the same reason we take privileged-access flows seriously — turned a would-be production incident into a five-minute local fix.

What does this cost, and when is it worth it?

The cost is real: a local cluster consumes more laptop RAM than Compose, and the initial Tiltfile-plus-ctlptl plumbing is an afternoon of work. The parity is worth it when your production target is Kubernetes and your bugs live in the platform surfaces — auth, RBAC, service discovery, admission — that Compose cannot represent. For a single stateless service with no cluster-specific behaviour, Compose may still be the right, lighter choice.

The break-even arrives fast for a multi-service backend. Once you have more than two services, shared dependencies, and any auth in the request path, the “works on my machine” gap between Compose and the cluster stops being an annoyance and starts being the source of your integration incidents. A per-service Tiltfile that composes into a whole-stack root gives each engineer the smallest environment they need and the full one when they want it.

Conclusion

Local Kubernetes development is not about running the whole platform on your laptop for its own sake — it is about closing the gap between where you write code and where it runs. Tilt and Kind make a real cluster fast enough to be your inner loop, Live Update keeps the Go cycle in seconds, and a self-contained-but-includable Tiltfile per service keeps onboarding to a single tilt up.

Two habits carry most of the value. Test against the real thing — real cluster, real identity stack, real curl — because mocks hide exactly the integration bugs that cost the most later. And keep your dependencies pinnable and free of surprise deprecations, which in 2025 means walking away from Bitnami’s legacy catalog. Invest the afternoon in the Tiltfile; you buy back the parity every day after.


Ismail Kaboubi leads cloud and platform engineering at Edixos.

Straight answers

Frequently asked questions

Should I use a real local Kubernetes cluster or Docker Compose?

Use a real cluster when your production target is Kubernetes. Docker Compose approximates networking and ignores namespaces, RBAC, and service discovery, so bugs in those surfaces only appear after deploy. A local Kind cluster reproduces the real substrate, at the cost of needing tooling like Tilt and ctlptl to keep the loop fast.

What does Tilt's Live Update actually do?

Live Update syncs changed files straight into a running container and re-runs the build step in place, instead of rebuilding and redeploying the whole image. Tilt describes it as deploying code to running containers in seconds, not minutes (tilt.dev, 2026). For Go, that means syncing .go files and recompiling the binary inside the pod.

Why avoid Bitnami images and charts for local development?

Bitnami deprecated its free, stable Docker Hub catalog effective August 28, 2025, moving versioned images to a bitnamilegacy repository that receives no further updates (bitnami/charts, 2025). Pinning a local stack to those images means silent rot. Plain upstream charts or vendor images keep the environment reproducible without a subscription.

Can I test authentication flows locally without a frontend?

Yes. Mint a session token from your identity provider and pass it as a header through your API gateway with curl, hitting API endpoints directly. This exercises the real auth stack rather than a mock, which is how we caught a client-library bug that a mocked setup would have hidden entirely.