A project's stack on a local Kubernetes cluster
Docker Compose stops being enough once the app needs ingress hostnames,
subdomain routing, or the same manifests it runs in production. A local
Kubernetes cluster gives you that, and adds three traps that cost an afternoon
each: an image the kubelet refuses to see, a hostname that routes nowhere while
everything reports healthy, and a login that fails only over plain http.
klocal drives the stack; the project's shape lives in a committed
.k8s-local/project.json, so the same tool works in every repo.
The rule that matters most
Never touch a cluster without checking the kubecontext first. One stale
context is the whole difference between a local bring-up and a production
deploy, and kubectl will not ask. Two independent checks, because neither is
enough alone:
- The name must be
rancher-desktop, docker-desktop, minikube,
colima, kind-* or k3d-*. A whitelist, not a heuristic — "contains the
word local" would accept a production cluster called localstack-prod.
- The API server address must be loopback or RFC1918, and the URL is
parsed, not string-matched. Names are chosen by whoever created the cluster,
so a remote cluster can be called
kind-prod; the address is the part that
naming cannot fake — provided you strip the userinfo first, since
https://127.0.0.1:x@prod.example.com:6443 is a legal URL whose host is
prod.example.com.
No context at all is refused the same way a remote one is. Every command that
reaches the cluster is guarded — including logs and psql, which read and
write through a pod — and the verified context is pinned with --context on
every call, so switching the kubeconfig mid-build cannot redirect the rest.
Pinning carries the name forward, not the cluster it named, so every write
path (up, rebuild, down) re-reads the server URL immediately before it
writes. The windows are real: a build takes minutes, and down waits at its
confirmation prompt for as long as you take to answer.
If a legitimate local context is refused, add its exact name to
kl_context_name_is_local and a case to the test suite; never loosen the pattern.
Do not
- Push a local image to a registry. Build it straight into the cluster's image
store. Keep
imagePullPolicy at IfNotPresent — it is the default for an
ordinary tag, but Always for :latest or no tag, and under Always the
kubelet ignores the local image and tries to pull it. Clear any inherited
imagePullSecrets too; that does not affect whether the local image is used,
it just removes a reference to a registry credential this cluster has not got.
And on kind, k3d or minikube the build must additionally be imported into the
node's own store, or the pod keeps running the previous image.
- Pick the build engine by which binary exists. Rancher Desktop ships
nerdctl
even when the engine is moby, where it cannot reach the k3s containerd socket.
Probe the socket: nerdctl --namespace k8s.io info, then docker info. And
do not stop at the socket either — with Rancher Desktop on containerd and
Docker Desktop installed, both probes answer. Decide by the context: a
docker-desktop cluster reads the docker store no matter what else replies.
- Report a read you could not do as a finding. "Not set on that Deployment" and
"I was not allowed to look" are different answers, and only the first is about
the manifest.
klocal status says which one it has.
- Let the app connect to Postgres as the container's superuser. It bypasses
every
FORCE ROW LEVEL SECURITY policy, so tenant isolation is silently off
while all tests still pass. Create a NOSUPERUSER NOBYPASSRLS role, and assert
it before declaring the stack up.
- Regenerate a password or signing key on a re-run. Redis keeps the
--requirepass it booted with, so the app crash-loops on WRONGPASS; a
rotated signing key breaks an already-running dev server. Read the existing
value first, generate only when it is absent.
- Declare an env var twice. It is malformed input to a merge-keyed list, and
what happens next depends on the path: a kustomize strategic-merge patch
collapses it and
keeps the first, silently, before
kubectl sees anything; a plain
manifest applied client-side keeps both, warns, and the kubelet uses the
last; server-side apply rejects it outright. Do not memorise a winner —
the point is that the manifest cannot tell you and only the live object can.
klocal status reports the duplicate next to the value actually in effect.
- Give the local datastores a volume. They are meant to be throwaway; say so in
the project's docs rather than making local state precious.
Commands
| Command |
Use it when |
klocal scaffold [dir] |
Starting local Kubernetes in a project for the first time |
klocal up |
Bringing the stack up, or after pulling changes to the manifests |
klocal status |
Something is wrong, or before trusting any claim about what is running |
klocal rebuild |
Code changed and the image tag did not |
klocal logs [workload] |
The app is crash-looping or answering wrong |
klocal psql [--app] |
Migrations as the owner, or --app to check that RLS actually bites |
klocal down |
Finished, or the local database needs a clean slate |
Common workflows
Set a project up. scaffold writes the manifests and config; every file
carries PROJECT placeholders and comments explaining what to decide.
klocal scaffold # from the project root
$EDITOR .k8s-local/project.json # then the PROJECT placeholders in deploy/
klocal up
Get secrets in without writing them to disk. klocal never handles secret
values. Point secret.hook at a command that creates the Secret, and it runs
from the project root with KL_NAMESPACE, KL_SECRET, KL_SECRET_ENV_FILE and
KL_KUBECTL exported. Use $KL_KUBECTL inside the hook — it carries the pinned
--context; a bare kubectl there is unpinned and follows the kubeconfig. Piping a vault straight into kubectl keeps values out of both the
filesystem and the transcript — see the onepassword skill for opgate.
"secret": { "hook": "opgate run -f api/.env.op -- ./deploy/scripts/local-secret.sh" }
Check what is actually running. Never report the manifest's intent as fact.
klocal status prints the live pods, services and ingress, whether the
datastores are ephemeral, and any env var a manifest declares twice alongside the
value that actually won.
Before you say it works
A green rollout means the pods started, not that the stack works. Confirm the
hostname answers, and confirm the answer came from the app rather than the
ingress controller — a 404 from the app and a 404 from Traefik look
identical until you read the body.
Further reading
references/bring-up.md — the procedure step by step, and why it is ordered so
references/engines.md — Rancher Desktop, kind, k3d, minikube: what differs
references/troubleshooting.md — symptom, cause, fix
references/example-cme.md — a real multi-tenant stack, worked end to end
1---2name: k8s-local3description: Run a project's whole stack on a local Kubernetes cluster with `klocal`, on Rancher Desktop, kind, k3d or minikube. Use when setting up local development for a project, when the app needs a database and a real hostname to run, when a locally built image will not start in the cluster, when a hostname routes nowhere, or when a local login fails over http.4---56# A project's stack on a local Kubernetes cluster78Docker Compose stops being enough once the app needs ingress hostnames,9subdomain routing, or the same manifests it runs in production. A local10Kubernetes cluster gives you that, and adds three traps that cost an afternoon11each: an image the kubelet refuses to see, a hostname that routes nowhere while12everything reports healthy, and a login that fails only over plain http.1314`klocal` drives the stack; the project's shape lives in a committed15`.k8s-local/project.json`, so the same tool works in every repo.1617## The rule that matters most1819**Never touch a cluster without checking the kubecontext first.** One stale20context is the whole difference between a local bring-up and a production21deploy, and `kubectl` will not ask. Two independent checks, because neither is22enough alone:23241. **The name** must be `rancher-desktop`, `docker-desktop`, `minikube`,25 `colima`, `kind-*` or `k3d-*`. A whitelist, not a heuristic — "contains the26 word local" would accept a production cluster called `localstack-prod`.272. **The API server address** must be loopback or RFC1918, and the URL is28 _parsed_, not string-matched. Names are chosen by whoever created the cluster,29 so a remote cluster can be called `kind-prod`; the address is the part that30 naming cannot fake — provided you strip the userinfo first, since31 `https://127.0.0.1:x@prod.example.com:6443` is a legal URL whose host is32 `prod.example.com`.3334No context at all is refused the same way a remote one is. Every command that35reaches the cluster is guarded — including `logs` and `psql`, which read and36write through a pod — and the verified context is pinned with `--context` on37every call, so switching the kubeconfig mid-build cannot redirect the rest.3839Pinning carries the **name** forward, not the cluster it named, so every write40path (`up`, `rebuild`, `down`) re-reads the server URL immediately before it41writes. The windows are real: a build takes minutes, and `down` waits at its42confirmation prompt for as long as you take to answer.4344If a legitimate local context is refused, add its exact name to45`kl_context_name_is_local` and a case to the test suite; never loosen the pattern.4647## Do not4849- Push a local image to a registry. Build it straight into the cluster's image50 store. Keep `imagePullPolicy` at `IfNotPresent` — it is the default for an51 ordinary tag, but `Always` for `:latest` or no tag, and under `Always` the52 kubelet ignores the local image and tries to pull it. Clear any inherited53 `imagePullSecrets` too; that does not affect whether the local image is used,54 it just removes a reference to a registry credential this cluster has not got.55 And on kind, k3d or minikube the build must additionally be imported into the56 node's own store, or the pod keeps running the previous image.57- Pick the build engine by which binary exists. Rancher Desktop ships `nerdctl`58 even when the engine is moby, where it cannot reach the k3s containerd socket.59 Probe the socket: `nerdctl --namespace k8s.io info`, then `docker info`. And60 do not stop at the socket either — with Rancher Desktop on containerd _and_61 Docker Desktop installed, both probes answer. Decide by the **context**: a62 `docker-desktop` cluster reads the docker store no matter what else replies.63- Report a read you could not do as a finding. "Not set on that Deployment" and64 "I was not allowed to look" are different answers, and only the first is about65 the manifest. `klocal status` says which one it has.66- Let the app connect to Postgres as the container's superuser. It bypasses67 every `FORCE ROW LEVEL SECURITY` policy, so tenant isolation is silently off68 while all tests still pass. Create a `NOSUPERUSER NOBYPASSRLS` role, and assert69 it before declaring the stack up.70- Regenerate a password or signing key on a re-run. Redis keeps the71 `--requirepass` it booted with, so the app crash-loops on `WRONGPASS`; a72 rotated signing key breaks an already-running dev server. Read the existing73 value first, generate only when it is absent.74- Declare an env var twice. It is malformed input to a merge-keyed list, and75 what happens next depends on the path: a **kustomize strategic-merge patch**76 collapses it and77 keeps the **first**, silently, before `kubectl` sees anything; a **plain78 manifest** applied client-side keeps **both**, warns, and the kubelet uses the79 **last**; server-side apply rejects it outright. Do not memorise a winner —80 the point is that the manifest cannot tell you and only the live object can.81 `klocal status` reports the duplicate next to the value actually in effect.82- Give the local datastores a volume. They are meant to be throwaway; say so in83 the project's docs rather than making local state precious.8485## Commands8687| Command | Use it when |88| ------------------------ | ---------------------------------------------------------------------- |89| `klocal scaffold [dir]` | Starting local Kubernetes in a project for the first time |90| `klocal up` | Bringing the stack up, or after pulling changes to the manifests |91| `klocal status` | Something is wrong, or before trusting any claim about what is running |92| `klocal rebuild` | Code changed and the image tag did not |93| `klocal logs [workload]` | The app is crash-looping or answering wrong |94| `klocal psql [--app]` | Migrations as the owner, or `--app` to check that RLS actually bites |95| `klocal down` | Finished, or the local database needs a clean slate |9697## Common workflows9899**Set a project up.** `scaffold` writes the manifests and config; every file100carries `PROJECT` placeholders and comments explaining what to decide.101102```bash103klocal scaffold # from the project root104$EDITOR .k8s-local/project.json # then the PROJECT placeholders in deploy/105klocal up106```107108**Get secrets in without writing them to disk.** `klocal` never handles secret109values. Point `secret.hook` at a command that creates the Secret, and it runs110from the project root with `KL_NAMESPACE`, `KL_SECRET`, `KL_SECRET_ENV_FILE` and111`KL_KUBECTL` exported. Use `$KL_KUBECTL` inside the hook — it carries the pinned112`--context`; a bare `kubectl` there is unpinned and follows the kubeconfig. Piping a vault straight into `kubectl` keeps values out of both the113filesystem and the transcript — see the `onepassword` skill for `opgate`.114115```json116"secret": { "hook": "opgate run -f api/.env.op -- ./deploy/scripts/local-secret.sh" }117```118119**Check what is actually running.** Never report the manifest's intent as fact.120`klocal status` prints the live pods, services and ingress, whether the121datastores are ephemeral, and any env var a manifest declares twice alongside the122value that actually won.123124## Before you say it works125126A green rollout means the pods started, not that the stack works. Confirm the127hostname answers, and confirm the answer came from the app rather than the128ingress controller — a `404` from the app and a `404` from Traefik look129identical until you read the body.130131## Further reading132133- `references/bring-up.md` — the procedure step by step, and why it is ordered so134- `references/engines.md` — Rancher Desktop, kind, k3d, minikube: what differs135- `references/troubleshooting.md` — symptom, cause, fix136- `references/example-cme.md` — a real multi-tenant stack, worked end to end