A single-cluster Kubernetes studylab — a deliberate learning environment for the stack I use professionally: k3s, Istio, GitOps, progressive delivery, observability, and event-driven autoscaling.
Not a homelab. Nothing here is meant to run anything useful — the goal is to build each piece by hand, understand why it exists, and prove it works.
Everything runs on one MacBook via Rancher Desktop. staging and prod are
namespaces in a single cluster, not separate clusters.
| Phase | Focus | State |
|---|---|---|
| 0 | Cluster base, core objects, probes | ✅ done |
| 1 | Namespaces, RBAC, NetworkPolicy isolation | ✅ done |
| 2 | Istio ingress + real TLS on *.rcamine.com |
✅ done |
| 3 | CI: build → GHCR → running in-cluster | ✅ done |
| 4 | GitOps with Argo CD, UI on argocd.rcamine.com |
✅ done |
| 5 | Canary 1→100%, Prometheus-gated (Argo Rollouts) | ⬜ |
| 6 | Prometheus + Grafana + Loki, dashboards | ⬜ |
| 7 | HPA → KEDA on Kafka consumer lag | ⬜ |
| 8 | Sealed Secrets, Velero, mTLS STRICT, tracing | ⬜ |
flowchart LR
U[Browser] -->|HTTPS 443| GW[Istio ingress gateway<br/>istio-system]
GW -->|VirtualService| WS[web · staging]
GW -.->|planned: apex| WP[web · prod]
CM[cert-manager] -->|DNS-01| CF[(Cloudflare DNS)]
CM -->|wildcard cert| GW
GH[GitHub Actions] -->|push image| GHCR[(ghcr.io)]
GHCR -.->|pull| WS
GHCR -.->|pull| WP
GIT[(git: main)] -->|polls| ACD[Argo CD<br/>argocd]
ACD -->|auto-sync + selfHeal| WS
ACD -->|manual sync| WP
WS <-.->|BLOCKED by NetworkPolicy| WP
Traffic path: browser → Istio ingress gateway (terminates TLS using a Let's Encrypt
wildcard cert) → VirtualService routes by Host header → app pod in staging.
Isolation: both staging and prod run default-deny ingress plus an additive
allow-rule for same-namespace, monitoring, and istio-system traffic. Cross-env
traffic is dropped in both directions — verified, not assumed.
root.yaml app-of-apps — the ONE hand-applied manifest
argocd/
infra.yaml Application → infra/
staging.yaml Application → staging/ (automated + selfHeal)
prod.yaml Application → prod/ (manual)
infra/
namespaces.yaml namespaces + istio-injection labels
clusterissuers.yaml Let's Encrypt staging + prod ACME issuers
certificate.yaml wildcard *.rcamine.com + apex (in istio-system)
gateway.yaml the ONE shared Gateway — every host on the cert
argocd-ingress.yaml VirtualService for argocd.rcamine.com
staging/
web.yaml Deployment + Service
client.yaml netshoot pod to curl from
networkpolicy.yaml default-deny ingress + additive allow-rules
istio-ingress.yaml VirtualService for staging.rcamine.com
prod/
web.yaml Deployment + Service
networkpolicy.yaml default-deny ingress + additive allow-rules
cert-manager-values.yaml Helm values (DNS-01 recursive nameservers)
app/ the demo Go service
.github/workflows/ CI: build + push to GHCR
BOOTSTRAP.md manual steps to recreate this from zero
One directory per Argo Application. The split is what gives each environment its
own sync, history, and rollback — a broken prod/ manifest can't block a staging
deploy, and argocd app rollback staging touches staging alone.
No Kustomize, deliberately.
staging/web.yamlandprod/web.yamlare near-identical copies. A Kustomizebase/+ overlays would remove that duplication, at the cost of a rendering step between what's in git and what lands in the cluster. For now the manifests are literal and greppable; revisit when maintaining two copies actually hurts.
A ~40-line Go HTTP server (app/) that reports its version and pod name:
$ curl https://staging.rcamine.com/
hello from web 6e6a4cc (pod web-64b8d66fcb-smj4x)
Deliberately minimal, but with the three properties the later phases need:
- version in the response — makes canary traffic-splitting visible
/healthz— backs liveness/readiness probes; readiness is the first gate a canary must pass- graceful shutdown on SIGTERM — drains in-flight requests instead of dropping them when pods are killed during a rollout
Built multi-stage into a distroless image: ~3 MB, no shell, non-root.
Distroless means there's no shell in the pod, so
kubectl execwon't work. Usekubectl debug -n staging <pod> --image=nicolaka/netshoot --target=webto attach an ephemeral container instead of baking tools into the image.
git push → GitHub Actions → GHCR → k3s pulls → Istio → https://staging.rcamine.com
.github/workflows/build.yml builds app/ on every push that touches it and pushes to
ghcr.io/rcamine/studylab-web, tagged with the full commit SHA. Deployments pin
that SHA — never :latest, which is a mutable pointer that moves under you and makes
"what is actually deployed?" unanswerable.
A few choices worth noting:
runs-on: ubuntu-24.04-arm— a native arm64 runner (free on public repos), so the image matches Apple Silicon without slow QEMU emulation.permissions: packages: write— workflow tokens are read-only by default; without this the push fails with an unhelpful 403.secrets.GITHUB_TOKEN— minted per run and discarded. No PAT to create or rotate.paths: app/**— editing a manifest doesn't trigger an image build.
The package inherits the repo's public visibility, so the cluster pulls anonymously and
needs no imagePullSecrets.
Nothing here is deployed with kubectl apply. Argo CD polls this repo and reconciles
the cluster to match it — a deploy is a commit.
git push → root (auto-sync) → argocd/*.yaml → infra / staging / prod
root.yaml is the only manifest applied by hand, and only once. It syncs argocd/,
so the Applications themselves are git-managed; adding an environment is a new file plus
a push. It lives at the repo root rather than inside argocd/ on purpose — an
Application that manages itself can be left unable to sync its own fix.
The UI is at https://argocd.rcamine.com — behind the same Istio gateway and wildcard
cert as everything else, so no port-forward. CLI: argocd login argocd.rcamine.com --grpc-web.
| Application | Path | Sync |
|---|---|---|
root |
argocd/ |
automated, selfHeal, no prune |
infra |
infra/ |
manual |
staging |
staging/ |
automated, selfHeal, prune |
prod |
prod/ |
manual |
Why staging and prod differ. selfHeal reverts any live change contradicting git
within seconds. In staging that's the point — drift becomes impossible and git is the
only way in. In prod it's hostile: during an incident you scale something by hand to
shed load, and an invisible controller silently undoes your intervention while you debug
why it "didn't work." Promotion to prod should be a decision, not a consequence of
pushing to main.
Two deliberate safety choices:
- No prune on
root. Pruning there wouldn't delete a Deployment — it would delete an entire Application, and with cascade semantics everything beneath it. - Namespaces carry
argocd.argoproj.io/sync-options: Prune=false,Delete=false. Namespace deletion is recursive and ignores Argo's ownership model, so removing one takes every object inside — including things Argo never managed: the hand-createdcloudflare-api-tokenSecret, the Let's Encrypt account keys, and (forargocd) Argo CD itself. Losing the ACME account key plus the cert can mean a week locked out of HTTPS on Let's Encrypt's duplicate-certificate rate limit, which no command fixes.
argocd app deletecascades by default and deletes every managed resource.kubectl delete appdoes not, unless theresources-finalizer.argocd.argoproj.iofinalizer is set. When restructuring — deleting an Application while keeping the workloads running — usekubectl, whose failure mode is harmless.
Useful commands:
argocd app diff staging # what would change, against the normalized live object
argocd app history staging # deploy log, each entry keyed to a git SHA
argocd app rollback staging 1 # revert to a previous synced revisionArgo compares content, not revision strings. A manually-synced app can sit at an older
.status.sync.revisionand still beSynced— it just means nothing in its directory changed.
| Component | Version | Install |
|---|---|---|
| k3s (Rancher Desktop) | v1.36.2+k3s1 |
Rancher Desktop, Traefik disabled |
| Istio | 1.30.2 |
sidecar mode, PERMISSIVE mTLS |
| cert-manager | v1.21.0 |
Helm |
| Argo CD | v3.4.5 |
kubectl apply --server-side (see BOOTSTRAP) |
| Go | 1.26 |
— |
External dependencies are only GitHub (repo + Actions + GHCR) and rcamine.com (registered at Namecheap, DNS delegated to Cloudflare). Everything else is self-hosted and free. Terraform is intentionally out of scope — provisioning is done by hand so the focus stays on what runs inside the cluster.
# the whole pipeline in one line: our image, our cert, through Istio
curl -s --resolve staging.rcamine.com:443:127.0.0.1 https://staging.rcamine.com/
# -> hello from web <short-sha> (pod web-xxxxxxxxx-xxxxx)
# HTTPS with a real, browser-trusted cert
curl -sI --resolve staging.rcamine.com:443:127.0.0.1 https://staging.rcamine.com/
# HTTP redirects to HTTPS
curl -so /dev/null -w '%{http_code}\n' -H 'Host: staging.rcamine.com' http://localhost/ # 301
# isolation holds: staging cannot reach prod
kubectl exec -n staging deploy/client -- curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://web.prod
# the Argo CD UI, same gateway and wildcard cert, no port-forward
curl -s -o /dev/null -w '%{http_code}\n' --resolve argocd.rcamine.com:443:127.0.0.1 https://argocd.rcamine.com/
# GitOps: everything reconciled, nothing drifted
kubectl get app -n argocd # root/infra/staging/prod all Synced + Healthy
# selfHeal works: this change is reverted within seconds
kubectl scale deploy/web -n staging --replicas=5
kubectl get deploy web -n staging -o jsonpath='{.spec.replicas}{"\n"}' # back to 2The
web.prodcheck returns 503, not 200 — the client's Envoy sidecar reportingupstream connect error ... remote connection failurebecause NetworkPolicy dropped the packets. (Without a sidecar in the path it shows as000, a curl timeout.) Either way it's blocked; a successful reply would have been 200.
Testing NetworkPolicy: use a long-lived pod and
kubectl exec, never a throwawaykubectl run --rm. k3s's policy controller takes several seconds to program a new pod's IP into the allow-ipset, so a fast probe races ahead of it and reports a false block.
Istio 503s after the laptop sleeps. Sidecar mTLS certs are ~24h and only rotate
while connected to istiod. After a multi-day suspend they expire and the gateway logs
CERTIFICATE_VERIFY_FAILED ... certificate has expired. mTLS is mutual, so restart
both ends:
kubectl rollout restart deploy/web deploy/client -n staging
kubectl rollout restart deploy/web -n prod
kubectl rollout restart deploy/istio-ingressgateway deploy/istio-egressgateway -n istio-systemThis is an artifact of running on a laptop — a cluster that stays online rotates these silently. Unrelated to the public Let's Encrypt cert, which is valid for 90 days and auto-renews around day 60.
The rotation timer runs on a monotonic clock that doesn't advance while the Mac is asleep, so a cert can sail past its halfway renewal point and never renew — even though istiod was reachable the whole time it was awake. Total uptime isn't what matters; being awake at the right moment is. Check before it bites:
istioctl proxy-config secret deploy/web -n staging.
Only ever run ONE Gateway per certificate. Every host on the wildcard cert is served
by the single rcamine-gateway in istio-system (infra/gateway.yaml), and
VirtualServices bind to it cross-namespace as istio-system/rcamine-gateway. Splitting
it back into a Gateway per host reintroduces a genuinely baffling bug:
Istio builds one Envoy filter chain per SNI, each with its own route config containing only that Gateway's hosts. Browsers coalesce HTTP/2 connections when a new hostname resolves to the same IP and the certificate already presented covers it (RFC 7540 §9.1.1) — both true for every
*.rcamine.comhost here. So a request for host B rides host A's connection, lands on A's route config, finds no route, and 404s. Whichever host you load first wins until you quit the browser.
curlnever reproduces it (it doesn't coalesce), so this looks like a browser or DNS problem for a long time. Diagnose it withistioctl proxy-config listeners deploy/istio-ingressgateway -n istio-system— one SNI match per Gateway is the tell. Istio documents this under common problems / network issues.
The shared Gateway already declares the apex rcamine.com alongside *.rcamine.com
(wildcards don't cover the apex), so exposing prod there later needs only a
VirtualService.