Local Deployment
This guide sets up the full lab on a local machine using k3d. No cloud accounts required.
./deploy-local.sh is the sole deploy path — the Makefile only builds images, runs tests, and
fetches/applies Grafana Cloud credentials (see grafana-cloud.md).
This is intentionally separate from the immutable GitHub Actions promotion path. Local deployment
builds/imports :local images for fast k3d iteration; it does not create a release manifest, sign
an image, or simulate DEV → QA → PROD promotion. See Immutable CI/CD Promotion for the
environment deployment contract.
Prerequisites
| Tool | Min version | Install |
|---|---|---|
| Docker | 24+ | docs.docker.com |
| k3d | v5+ | curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash |
| kubectl | v1.28+ | kubernetes.io/docs |
| helm | v3.14+ | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash |
| Python 3 | 3.9+ | system package |
| yq | v4+ | deploy-local.sh reads conf.yml through it |
WSL2 note: Port 80 is blocked on WSL2. The cluster maps host port 8080 → cluster port 80. All
localhost:80references becomelocalhost:8080.
Quick start (one command)
./deploy-local.sh # cluster + builds + manifests + Helm install (5-15 min cold)
The collector mode is controlled entirely by conf.yml:
monitoring:
mode: cloud # or: local
mode: cloud(default) — thegrafana/k8s-monitoringHelm chart’s Alloy agents ship to Grafana Cloud Tempo/Mimir/Loki.deploy-local.shrendersvalues-cloud.yaml.tmplfrommonitoring.grafana_cloudinconf.ymland installs the chart unconditionally.mode: local— a bespoke Alloy DaemonSet underk8s/monitoring/grafana/local/exports to in-cluster Jaeger/Prometheus/Loki/Grafana. Pass--with-helmto also install the Helm chart in this mode (it’s otherwise optional locally).
For subsequent iterations once the cluster exists:
./deploy-local.sh --skip-cluster --skip-build # manifests-only, <1 min
See CLAUDE.md for the full flag
list and the safety checks deploy-local.sh runs before every apply (k3d context guard, NodePort
drift check, Secret-key contract validation).
Step-by-step walkthrough
1. Cluster creation
deploy-local.sh creates the k3d cluster and maps host ports read from conf.yml’s
cluster.ports[] (filtered by ports[].mode — local-only ports like Jaeger/Prometheus/Grafana are
skipped entirely in cloud mode):
| Host port | Cluster target | Service | Mode |
|---|---|---|---|
8080 | port 80 on loadbalancer | Frontend + API (Traefik ingress) | always |
16686 | NodePort 30686 on server-0 | Jaeger UI | local only |
3000 | NodePort 30300 on server-0 | Grafana | local only |
9090 | NodePort 30090 on server-0 | Prometheus | local only |
15672 | NodePort 30672 on server-0 | RabbitMQ Management | always |
Corporate proxy (Zscaler) — automated: if /usr/local/share/ca-certificates/zcert.crt exists on
the host, deploy-local.sh stages it into each Docker build context and injects it into the k3d
server node’s trust store, then reloads the k3d nginx load balancer. No-op on non-corporate machines
(an empty placeholder is staged so COPY zcert.crt in each Dockerfile never fails).
2. Build and import Docker images
Builds all four images (docker build --network=host) then k3d image imports them directly — no
external registry pull required at deploy time. FARO_API_KEY, when set in the shell, is forwarded
as a build arg to the frontend build so the webpack plugin can upload source maps.
3. Apply manifests
deploy-local.sh applies, in order (per conf.yml’s manifests.{infra,datastores,app,post}):
k8s/infra/(namespace, secrets, PDBs, network policies) — onekubectl apply -fper filek8s/datastores/{mysql,postgres,redis,rabbitmq}/— waits forkubectl -n otel-lab wait --for=condition=ready pod -l tier=datastore --timeout=180s- Monitoring manifests for the active
monitoring.mode(local DaemonSet, or nothing — cloud mode is entirely Helm-managed) k8s/app/{gateway,order,notification,frontend}/- Post-stage manifests (ingress, cert-manager issuer when
security.tls.enabled)
4. Grafana Cloud knobs in conf.yml
deploy-local.sh sources the env file named by monitoring.grafana_cloud.use_env and writes its
nine GRAFANA_CLOUD_*/FARO_* credentials into the in-cluster grafana-cloud-secrets Secret — see
grafana-cloud.md for the full model.
scripts/fetch-grafana-cloud-conf-from-akv.sh populates that same env file from Azure Key Vault;
there’s no separate conf.yml-fields path:
grafana_cloud:
use_env: ".env"
5. Helm monitoring stack
deploy-local.sh installs grafana/k8s-monitoring (version pinned in conf.yml’s
monitoring.helm.version) using the values file selected by
monitoring.helm.values_file_by_mode.<mode> — unconditionally in cloud mode, only when
--with-helm is passed in local mode. See helm.md for
the Alloy role breakdown.
Important: application services send OTLP to
alloy-receiverin themonitoringnamespace. If the Helm chart isn’t installed, traces and metrics are silently lost —./scripts/debug.shchecks this reachability.
6. Verify deployment
./scripts/debug.sh # mode-aware: conf.yml values, pod state, Alloy exporter counters,
# remote-write reachability probe, alloy-receiver endpoint check
curl -s http://localhost:8080/healthz
curl -s http://localhost:8080/api/projects
7. Generate traffic
kubectl apply -f k8s/loadtest/
The k6 script runs for 3 minutes (30s ramp-up → 2m sustained → 30s ramp-down) at up to 20 concurrent
users. It creates projects and orders, reads notifications, and occasionally hits /api/slow and
/api/error.
Switching between local and cloud collector modes
Edit monitoring.mode in conf.yml, then re-run:
./deploy-local.sh --skip-cluster --skip-build
local: appliesk8s/monitoring/grafana/local/(hand-rolled Alloy DaemonSet exporting to in-cluster Jaeger/Prometheus/Loki), plusk8s/monitoring/local/backends. Pass--with-helmto also install the Helm chart.cloud: the Helm chart’s Alloy agents export to Grafana Cloud. No in-cluster Jaeger/Prometheus/Loki/Grafana are deployed. There is no dual-export — switching modes changes the destination, it doesn’t add one.
Accessing local backends (monitoring.mode: local)
| Service | URL | Credentials |
|---|---|---|
| Angular SPA | http://localhost:8080 | — |
| Grafana | http://localhost:3000 | admin / admin |
| Jaeger | http://localhost:16686 | — |
| Prometheus | http://localhost:9090 | — |
| RabbitMQ | http://localhost:15672 | guest / guest |
Alloy debug UI (pipeline graph, component status) — both modes, once the Helm chart is installed:
kubectl port-forward svc/grafana-k8s-alloy-receiver 12345 -n monitoring
open http://localhost:12345
Tear down
./deploy-local.sh --teardown # delete the k3d cluster entirely
Common issues
nginx LB stale IP after container restart
If kubectl commands hang after a Docker/WSL2 restart, the nginx load balancer has a cached stale
IP. Reload it:
docker exec k3d-otel-lab-serverlb nginx -s reload
deploy-local.sh does this automatically after Zscaler cert injection, but a Docker Desktop restart
between runs can reintroduce it.
Images not found in k3d
If pods show ErrImagePull, the images were not imported into k3d’s internal registry — re-run
./deploy-local.sh without --skip-build.
Datastores not ready
If app pods crash on startup with DB connection errors, the datastores may not be ready yet:
kubectl -n otel-lab get pods -l tier=datastore
kubectl -n otel-lab wait --for=condition=ready pod -l tier=datastore --timeout=180s
kubectl -n otel-lab rollout restart deployment/gateway-api deployment/order-api
Alloy not receiving OTLP
Check the receiver is running and the endpoint is correct:
kubectl -n monitoring get pods -l app.kubernetes.io/component=alloy-receiver
kubectl -n otel-lab exec deploy/gateway-api -- env | grep OTEL_EXPORTER_OTLP
# Should be: http://grafana-k8s-alloy-receiver.monitoring.svc.cluster.local:4317
Or run ./scripts/debug.sh, which checks this reachability automatically.