Projects

GitOps Delivery Platform with Service Mesh

A Kubernetes delivery platform where git is the only way in — ArgoCD app-of-apps, Istio canaries counted rather than asserted, secrets from Vault. Built, measured, broken, fixed and torn down: seventeen defects found, sixteen fixed, one published as a failure.

Kubernetes (k3s) · ArgoCD · Istio · Terraform · Helm · Kustomize · Prometheus · Loki · Vault · GitHub Actions

Built & drilled

View repository →

Lab build on a single t3.xlarge in a personal AWS account — provisioned by Terraform, measured, and destroyed on 10 October 2026. Every figure on this page comes from the runs in the screenshots. The ArgoCD admin password and commit author details are redacted; the addresses shown are private, inside a VPC that no longer exists.

At a glance

Stackk3s v1.36.4 · Istio 1.31.1 · ArgoCD v3.5.3 · kube-prometheus-stack 65.1.1 · Loki · HashiCorp Vault · External Secrets 2.11.0 · Terraform
ShapeTwo repositories — application source and declared cluster state, deliberately separated
InfrastructureOne t3.xlarge, provisioned by Terraform, destroyed the same day
MeasuredSelf-heal in 4 seconds · canary split 171 / 29 against a configured 90 / 10 · full stack at 3.9 GiB
Total costAbout $6

What it is

A platform where deploying is automatic and releasing is a human decision — and where neither can happen outside git.

CI builds a container image, tags it with the commit SHA, and writes that tag into a second repository. ArgoCD watches that second repository and nothing else. After bootstrap there is exactly one kubectl apply in the entire project — the root Application — and everything after it arrives through a commit.

The constraint worth stating precisely is what CI holds. It has no kubeconfig and no cluster credentials, so a compromised application pipeline has nothing to steal that talks to the cluster directly. Its only route in is a commit to the GitOps repository — attributed, reviewable and revertable, which is the security argument for splitting the repositories.

That is not the same as cannot change the cluster. The token CI uses to write the image tag is a fine-grained one with write access to the GitOps repository alone — and ArgoCD applies whatever lands there. Closing that last gap means CI opening a pull request instead of pushing, or a repository rule limiting the bot to the two values files it actually edits. The separation moves the attack from a stolen kubeconfig to a visible commit; it does not remove it.

Architecture

k8s-platform-app two FastAPI services non-root, UID 10001, read-only FS GitHub Actions CI push image GHCR tagged with the commit SHA image tag platform-gitops Helm chart — one deployable version Kustomize overlays — dev / prod ArgoCD Applications, by sync wave Istio Gateway · DestinationRule · VS PrometheusRules · dashboards ExternalSecret references only Terraform for the node itself declared state — the only input WHAT CI HOLDS No kubeconfig. No cluster credentials. One token: write access to platform-gitops, nothing else. ArgoCD applies what lands there — so the commit, not the cluster, is the gate. k3s on one t3.xlarge · Terraform · SSH only, from a /32 pulls — the only path in ArgoCD app-of-apps · watches nothing else wave 0 secrets ESO ← Vault wave 1 observability kube-prometheus-stack wave 2 logging Loki · promtail · rules wave 3 webapp web → api v1 / v2 wave 4 istio-traffic VirtualService weights Each wave is one Application. The order is declared — and, it turned out, only partly enforced.
CI's only output that reaches the cluster is a commit. ArgoCD pulls from one repository and applies it in waves, so a Secret exists before the app that reads it, and CRDs exist before the rules that need them.

Helm and Kustomize — not two tools doing one job

Helm packages and templates; Kustomize expresses environment differences. overlays/prod renders the same chart three times — api-v1, api-v2, web — then patches replica counts and topology spread on top. dev renders it twice, smaller, with no canary.

The chart deliberately does not template a Service

The Service spans every version. That is what lets Istio split traffic between v1 and v2 behind one stable DNS name. If each version’s release owned a Service, two releases would fight over the same object — so Services live in base/ as plain manifests.

Terminal: kubectl kustomize overlays/prod fails with 'trouble configuring builtin HelmChartInflationGenerator … must specify --enable-helm' and exit code 1; with --enable-helm it renders 3 Deployments, 1 Namespace, 2 Services and 3 ServiceMonitors.
Without --enable-helm, kustomize v5.7.1 refuses the Helm generator and exits 1. With it, nine resources render. Older kustomize versions fail silently here instead — which is why this one flag comes up again below.

Infrastructure as code

The archive this project grew from had no provisioning at all — the bootstrap script simply assumed a machine existed. That gap is filled by a small Terraform stack: one EC2 instance, one security group, one key pair, a subnet, an internet gateway and its routing. Nine resources.

DecisionReason
Port 22 only, from a /32Every UI — ArgoCD, Grafana, Prometheus, Alertmanager, Kiali — is reached through an SSH tunnel. Nothing else is exposed.
An existing SSH key, not tls_private_keyThat resource writes the private key into terraform.tfstate in plaintext. State is the one file in an IaC repo you cannot afford to leak.
IMDSv2 requiredThe v1 metadata endpoint is reachable from inside any pod, which turns an SSRF into a credential read. Free to enforce.
No Elastic IPAn EIP bills while unattached — precisely when you have forgotten it exists.
ignore_changes = [ami]The AMI resolves to “most recent”, so an Ubuntu republish would otherwise render as a plan that destroys a day-old cluster.

The teardown script verifies against AWS, not against Terraform, and exits non-zero if anything survives. Those are two different claims, and only the second one matters when the bill arrives. That distinction was not academic: one terraform destroy silently failed to run and left the instance billing for 24 hours before the check caught it.

What the platform does, measured

Self-heal

A deployment scaled to 9 replicas by hand is back to the git-declared 3 four seconds later.

Terminal: demo-selfheal.sh reports 3 replicas declared in git, scales api-v1 to 9 by hand, watches for ArgoCD, and prints 'Reverted to 3 after 4s. Nobody was paged. Nothing was approved. Git won.' The deployment shows 3/3 ready.
Nobody was paged. Nothing was approved. Git won. Usually described as reliability — it is also a security control: an unauthorised change to production undoes itself.

Progressive delivery, counted rather than asserted

Asserting that a 90/10 split was configured proves nothing. Counting 171 responses against 29 proves the mesh is actually routing. Both rows below were measured through the ingress gateway, counted from the version each response reported — not read back from the VirtualService.

Weights in gitRequestsv1 measuredv2 measuredDeviation
90 / 10200171 (85.5%)29 (14.5%)−4.5 pp
50 / 50200102 (51.0%)98 (49.0%)+1.0 pp
Terminal: demo-canary.sh sends 200 requests; responses by api version are v1 171 (85.5%) and v2 29 (14.5%), against configured weights of 90 and 10.
171 against 29 on a configured 90/10.

The deviation is correct behaviour, not a misconfiguration. Istio’s distribution is probabilistic per request, not a strict round robin, and the smaller arm deviates more at n=200 — exactly as it should. Reported rather than rounded away.

One tidier number was deliberately thrown out. A cumulative http_requests_total ratio read 91.8 / 8.2 at one point — a neat-looking confirmation of 90/10. Those counters included traffic sent by kubectl exec straight to the pods, which bypasses the VirtualService entirely. A plausible number measuring the wrong thing is worse than no number.

Changing the weights is a git commit, not a kubectl command:

edit istio/api-traffic.yaml  →  commit  →  push  →  ArgoCD applies  →  re-measure
Kiali versioned app graph for webapp-prod: istio-ingressgateway to web v1, then to api, which splits to api v1 and v2. The side panel reads 3 apps (4 versions), 2 services, 5 edges, 100% success.
istio-ingressgateway → web → api → {v1, v2} — two hops, four versions, five edges, 100% success. The second hop is what makes this a service graph rather than a single box — and, as it turned out, what broke header routing.

Metrics and logs, correlated

Grafana Explore split view: on the left a Prometheus graph of request rate by version, v1 and v2 as separate lines, both rising sharply at the end; on the right a Loki query for namespace webapp-prod, app api, with a logs-volume histogram spiking at the same time.
Request rate broken down by canary version on the left; Loki logs for the same namespace and time window on the right. The version label is what makes the canary countable — a blended error rate would hide a bad canary behind a good stable.
The same Grafana split view with the Loki query filtered to lines containing 500 in webapp-prod; the logs-volume histogram shows two tall bars at the moment the request-rate graph rises.
The same window, with LogQL narrowed to the 500s: {namespace="webapp-prod"} |= "500".

Alerting that has actually fired

The application exposes /boom and /slow for exactly one reason: an alert you have never seen fire is a configuration, not an alert. Driving errors through /boom pushed the error-budget rule past its for: 10m window and fired WebappErrorBudgetBurn at severity critical.

Kiali overview: one cluster, one control plane, five Istio configs valid; Service Insights lists api with 24.59% errors and web with 0.00%.
24.59% errors on api, 0.00% on web — only api has the failure endpoint.
Kiali traffic graph during the error demo: the normal path from the ingress gateway through web to api is green, while a separate red edge runs from a curl-boom test pod to api; the panel shows 75.24% success and 24.76% errors.
The red edge is the curl-boom test pod driving /boom straight at api — the normal path through the gateway stays green.

Secrets that are never in git

Terminal: kubectl get externalsecret in webapp-prod shows webapp-db from ClusterSecretStore vault-backend, refresh interval 1h, status SecretSynced, ready True; the resulting Secret webapp-db holds 2 keys. No Deployment exists in the namespace yet; the only pod is a curl test pod.
SecretSynced, and the Secret in place before any Deployment exists in the namespace — the order the sync waves are there to give. (The one pod listed is a leftover curl test pod.)

The repository contains the reference; External Secrets fetches the value from Vault at runtime. Rotating a credential in Vault propagated to the cluster with no commit, no pipeline run and no redeployment — with one caveat, under Honest limitations.

What went wrong

This is the part worth reading. Seventeen defects surfaced during the build, between a local dry run on 23 September and the final session on 10 October. Each is recorded in the repository with its symptom, the misleading error it produced, and the reasoning — not just the fix. The most instructive:

A route that silently discarded every packet

The AWS account’s default VPC had been stripped at some point: all six default subnets deleted, the internet gateway deleted, and the main route table still carrying 0.0.0.0/0 to the dead gateway in state blackhole.

A blackhole route is not an error. Had the stack inherited that route table, terraform apply would have succeeded and the instance would have booted and passed its status checks — and SSH would have timed out. With no gateway there is nothing for inbound traffic to enter through, and the replies follow the default route into the blackhole anyway. An SSH timeout sends you to the security group and your own /32; the route table of a default VPC is the last place anyone looks. (The full story.)

The Terraform now declares its own gateway, subnet and route table rather than inheriting account state nobody is maintaining.

set -e turning a cosmetic error into a silent omission

ArgoCD’s ApplicationSet CRD is larger than Kubernetes’ 262144-byte limit on the annotation that client-side apply writes, so kubectl apply refused it. That on its own is harmless — ArgoCD runs fine without ApplicationSet.

But under set -euo pipefail the failure aborted the install script before a line further down the same file:

kubectl -n argocd patch configmap argocd-cm --type merge \
  -p '{"data":{"kustomize.buildOptions":"--enable-helm"}}'

That is the flag without which none of the Helm-based overlays render. With an older kustomize it is the project’s signature failure — ArgoCD reports a successful sync having deployed nothing but the Services. An oversized annotation on an unrelated CRD would have surfaced an hour later as that, with nothing connecting the two. The fix is server-side apply, which tracks ownership in managedFields and has no such limit. (The full story.)

One allowlist entry short, four applications broken

Terminal watch of kubectl get applications: istio-traffic, root and secrets Synced and Healthy; logging, observability and promtail Unknown; observability-config OutOfSync and Missing; webapp OutOfSync.
Three Applications in Unknown, one Missing — while istio-traffic, the last wave, reports Synced and Healthy. That second detail is its own finding: the waves did not hold.

The ArgoCD AppProject permitted only the GitOps repository as a source. Three Applications pull Helm charts from upstream; they were refused — and because kube-prometheus-stack never installed, two further Applications failed with failed to discover server resources for group version monitoring.coreos.com/v1.

Only one of the four errors named the real cause. Debugging from the last error would have led into chart internals and found nothing.

It also exposed a genuine design tension. The original project denied ClusterRole and ClusterRoleBinding, on the reasoning that privilege escalation through a committed manifest is the GitOps-specific attack. That reasoning is correct — and incompatible with installing a monitoring operator that legitimately needs cluster RBAC. There is no clever resolution: a platform that installs an operator through GitOps is a platform whose repository can create cluster RBAC. The project widened the allowlist and says so in the file, rather than keeping a comment describing a control no longer there. (It is also why the CI token above matters more than it first appears.) (The full story.)

Security and observability in direct conflict

STRICT mutual TLS across the application namespace meant the Istio sidecar reset every Prometheus scrape. Targets sat permanently DOWN with connection reset by peer, while /metrics answered 200 from inside the pod. Nothing was broken — the mesh was enforcing precisely what it had been told to.

The quick fix is to relax mTLS to PERMISSIVE. It works in one line and quietly discards the control, because the application port carries real traffic as well as metrics.

It took four attempts to do it properly:

  1. Scrape the app port — targets appeared and failed. Right diagnosis, wrong layer.
  2. ServiceMonitor with targetPort: 15020 — targets vanished entirely. A ServiceMonitor discovers through the Service’s endpoints, so it can only select a port the Service exposes.
  3. PodMonitor with targetPort: 15020 — also zero targets. Both forms compile down to a relabel that keeps only declared container ports, and 15020 listens without being declared.
  4. Publish 15020 as a Service port, then attach a ServiceMonitor to it — works. A Service forwards to any pod port, declared or not, which is the one property neither monitor CRD has.

Attempt 2 is the one worth remembering: it made the symptom worse while being a step closer. A failing target tells you something is wrong; a missing target looks like nothing was ever created.

STRICT mTLS was kept. Istio’s merged telemetry port carries the application’s own metrics alongside Envoy’s, and Istio exempts that port from mTLS precisely so monitoring survives. The cleaner production answer is a third thing neither option does — serve /metrics on its own port — and that is an application change, noted as the follow-up.

And the fix created a fault two files away. Publishing 15020 made the Service multi-port, and a VirtualService destination pointing at a multi-port Service no longer identifies a port. Nothing failed: routing continued, ArgoCD stayed Synced and Healthy, every pod stayed Running. Only istioctl analyze saw it — IST0112 — and a Kiali badge easily mistaken for the demo’s deliberate errors. Pinning port.number: 8000 on the three destinations cleared it. (The full story.)

Kiali Istio Config for webapp-prod: DestinationRule api, VirtualService api, PeerAuthentication default, Gateway web-gateway and VirtualService web-ingress, each with a green valid tick.
All five Istio objects valid after the port fix — including the STRICT PeerAuthentication that was kept rather than relaxed.

And one left open

Header-based canary routing — x-canary: true should pin a request to v2 — does not work, and the repository says so.

Terminal: CANARY_HEADER=1 demo-canary.sh 50 sends x-canary true, expecting 100% v2; responses are v1 46 (92%) and v2 4 (8%), against configured weights of 90 and 10.
Expected 0 / 50. Got 46 / 4.

Expected 0 v1 / 50 v2. Got 46/4 at 90/10 weights, and 27/23 at 50/50. The result tracks the weights, which is the proof: if the header rule were matching, the weights would be irrelevant.

The mesh configuration is correct. The web service simply never forwards the inbound header on its outbound call to api, so the header dies at the first hop, and api’s rule evaluates against a request that no longer carries it.

Header-based routing is often described as free with Istio. It is free for single-hop paths. On a two-hop path it is an application contract — the same requirement distributed tracing has, for the same reason. A mesh can route on context it can see, but only the application can carry that context across a call it originates.

Left open rather than patched, because fixing it properly means an application change, a CI run and a redeploy — which is exactly the deploy-versus-release distinction the rest of the project is about. (The full story.)

Honest limitations

These are in the repository too, and they stay there.

  • Vault runs in dev mode — unsealed, in-memory, root token, and it loses everything when the pod restarts. That is correct for demonstrating the External Secrets pattern and wrong for anything else. It bit during the build: a restart wiped the seeded secrets, and they had to be re-seeded.
  • Rotation updates the Secret, not the running process. envFrom is read at container start, so pods keep the old value until restarted. Making that automatic needs a file mount with a watcher, or Reloader. Claiming zero-restart rotation would be overclaiming.
  • Alertmanager posts to a webhook, not a real destination. An alert that fires into nothing is the same class of problem as everything else here.
  • Sync waves did not fully hold. Wave 4 reached Healthy while waves 1 and 2 were failing validation, because an Application rejected at validation sits in Unknown, which does not block the way Progressing does. The ordering guarantee is weaker than the documentation implies.
  • CI’s token can write to the GitOps repository, and ArgoCD applies what it finds there. The repository split removes cluster credentials from CI; it does not make a pipeline compromise harmless. A pull-request gate or a path-restricted rule for the bot is the missing piece.
  • Two demonstrations were not run — pruning a removed resource, and the final 0/100 promotion. The session ended first, and they are recorded as not run rather than left out.
  • The sizing estimate was wrong, in the safe direction. The design argued for 16 GB on the basis of a ~7.4 GiB footprint. The measured figure was 3.9 GiB; an 8 GB node would have fitted. The larger instance still stands for the follow-on project, but the original number was a guess carrying more confidence than it had earned.
  • Zabbix earns its place only where non-Kubernetes infrastructure exists. The repository keeps a values file and a note on when it is warranted; in a greenfield Kubernetes estate you would not add it. Saying that is a more useful answer than deploying it and implying it was necessary.

Cost discipline

A t3.xlarge bills about $0.166 an hour — roughly $120 a month if left running. The whole build came to about $6, because the environment is created and destroyed per session:

./scripts/lab-up.sh     # terraform apply, wait for SSH
./scripts/lab-down.sh   # terraform destroy, then verify against AWS

The teardown script checks for instances, orphaned EBS volumes and — the one people forget — unattached Elastic IPs, which bill precisely when nobody remembers they exist. It exits non-zero if anything survives.

Rebuilding is about an hour, entirely from git. Nothing in the cluster is precious, and being able to destroy it on a whim is the argument for the whole architecture rather than a cost-saving afterthought.

The build, start to finish

Expand the full walkthrough — bootstrap to running platform

1 · A fresh node becomes a cluster

Terminal: the k3s installer selects v1.36.4+k3s1, verifies and installs the binary, and the node reports Ready on Ubuntu 24.04.5 with containerd 2.3.4; then Helm v3.22.0 and istioctl 1.31.1 are downloaded and installed.
k3s v1.36.4 with Traefik disabled — Istio's ingress gateway is the entry point, and two ingress controllers fighting over port 80 is a confusing first afternoon.
Terminal: Istio core, istiod and the ingress and egress gateways installed; Kiali deployed; then free -h shows 15 GiB total and 1.4 GiB used.
Istio, Kiali, and 1.4 GiB of 15 GiB consumed before ArgoCD and Prometheus arrive — well under the 2.5 GiB the README predicted.

2 · Secrets before anything that needs them

Terminal: vault-seed.sh waits for Vault, enables Kubernetes auth, uploads the webapp policy, binds it to the External Secrets ServiceAccount, and seeds secret paths for webapp/database and grafana/admin, showing metadata only.
Kubernetes authentication rather than a static token: the operator proves its identity with its ServiceAccount, and Vault decides what that identity may read. The policy is scoped to exactly two paths — an operator that can read every secret in Vault is not much better than a secret in git.
Terminal: kubectl api-resources for the external-secrets.io group lists ClusterExternalSecret, ClusterSecretStore, ExternalSecret and SecretStore at external-secrets.io/v1.
A small thing that costs an hour if you get it wrong: External Secrets 2.x serves v1 only. Manifests written against v1beta1 fail with no matches for kind, which reads like a typo in a file that is actually correct for the version it was written against.

3 · ArgoCD, and the flag everything depends on

Terminal: ArgoCD services, deployments, statefulset and network policies serverside-applied; argocd-cm patched to enable the Helm renderer in Kustomize; the repo server restarted; the initial admin password line is redacted; ArgoCD is up.
Installed with server-side apply — client-side apply cannot handle a CRD larger than the annotation limit. The --enable-helm patch lands at the end, and the whole platform depends on it. (Admin password redacted.)

4 · Handing the cluster to git

Terminal: kubectl apply of the platform AppProject and the root Application, then a timed watch of the applications that ran for 1 minute 40 seconds.
The last two imperative commands in the project. The 1m40s is how long the watch ran, not a time-to-healthy: the first attempt failed on the allowlist defects, so a clean empty-to-Healthy figure was never measured — and is not claimed.
ArgoCD v3.5.3 application tree for root: Healthy, Synced to main, auto-sync enabled, with child applications istio-traffic, logging and observability. Author fields are redacted.
The app-of-apps: one root Application, and every other Application a child of it. (Author redacted.)
ArgoCD applications tiles: istio-traffic, logging, observability and observability-config in project platform, each Healthy and Synced; the filter panel counts 8 Synced and 0 OutOfSync. The observability tile also shows a Sync failed badge.
Eight Applications, all Synced and Healthy. The Sync failed badge on observability is the result of an earlier sync operation — ArgoCD reports the current state and the last operation separately.
ArgoCD tree for the webapp application: namespace webapp-prod, services api and web, deployment api-v1 with two ReplicaSets, and three api-v1 pods each 2/2 running.
Application → Deployment → ReplicaSet → Pods, every one of them traceable to a commit. The 2/2 is the app plus its Istio sidecar.

5 · Observability wired up

Prometheus targets page showing serviceMonitor scrape pools for the kube-prometheus-stack Alertmanager, each 1/1 up.
Prometheus discovering its targets through ServiceMonitors — the same mechanism that took four attempts to point at the meshed application.
Terminal: demo-canary.sh sends 200 requests; responses are v1 102 (51.0%) and v2 98 (49.0%) against configured weights of 50 and 50.
102 against 98 on a configured 50/50 — a one-percentage-point deviation across 200 requests.

Repositories

platform-gitopsDeclared cluster state, Terraform, the demonstration scripts, and docs/DEMO-RESULTS.md — every defect with its reasoning
k8s-platform-appTwo FastAPI services, non-root on numeric UID 10001 with a read-only root filesystem, and the CI that publishes them

Both images run non-root on a numeric UID rather than a username — runAsNonRoot cannot verify a name at admission time, so an image whose USER is a name gets rejected by the policy layer that comes next.

What comes next

The same delivery pattern, rebuilt from git, gained a Zero-Trust supply-chain layer: Kyverno admission policy, Falco runtime detection, Trivy scanning and signed images — each attacked on purpose. This project builds the delivery path; that one makes it prove every artifact it deploys.