GitOps source of truth for the From Commit to Cluster tutorial.
This repo is the half of the system that ArgoCD watches. It contains the infrastructure bootstrap (Terraform: kind cluster + ArgoCD), ArgoCD Application and ApplicationSet definitions, and per-environment Helm values for service-demo. The CI/CD pipelines in service-demo commit to this repo; ArgoCD reads from it and reconciles the cluster. Neither pipeline touches the cluster directly.
flowchart LR
subgraph sd["service-demo repo"]
CI["CI / CD workflows"]
end
subgraph gd["gitops-demo repo (this one)"]
VALUES["values/*.yaml"]
APPS["argocd/apps + appsets"]
end
subgraph cluster["cluster"]
ARGOCD["ArgoCD"]
DEV["dev"]
PREPROD["preprod"]
PROD["prod"]
PR["pr-N (ephemeral)"]
end
CI -- "commits image tag" --> VALUES
ARGOCD -- watches --> VALUES
ARGOCD -- watches --> APPS
ARGOCD -- syncs --> DEV
ARGOCD -- syncs --> PREPROD
ARGOCD -- syncs --> PROD
ARGOCD -- syncs --> PR
CI/CD only ever writes to values/ in this repo. It never talks to the cluster.
ArgoCD is the only thing that talks to the cluster, and it only acts on what it
reads from this repo. That separation is the point of GitOps.
terraform/
main.tf # kind cluster + namespace + ArgoCD Helm release
variables.tf
outputs.tf
bootstrap/
argocd-values.yaml # ArgoCD Helm values (NodePort 30080, insecure mode)
argocd/
apps/
dev.yaml # Application: dev namespace (manual sync — tracks releases)
preprod.yaml # Application: preprod namespace (auto-sync)
prod.yaml # Application: prod namespace (manual sync)
appsets/
ephemeral.yaml # ApplicationSet: one app per open PR (pullRequest generator)
values/
dev/service-demo.yaml # Updated by release.yml alongside prod (same v{X.Y.Z} tag)
preprod/service-demo.yaml # Updated by cd.yml on every merge to main (main-{sha} tag)
prod/service-demo.yaml # Updated by release.yml on manual release (v{X.Y.Z} tag)
pr/ # Written by ci.yml per PR; cleaned up on PR close
- Terraform >= 1.9
- kind
- kubectl
- Helm (used by Terraform, not called directly)
- ArgoCD CLI
cd terraform
terraform init
terraform applyCreates a kind cluster named gitops-demo, three namespaces (dev, preprod, prod), and installs ArgoCD via Helm on NodePort 30080.
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -dargocd login localhost:30080 --username admin --insecure
# UI: http://localhost:30080kubectl apply -f argocd/apps/This creates three ArgoCD Applications — service-demo-dev, service-demo-preprod, and service-demo-prod. They will show as OutOfSync until a values file contains a real image tag.
# Create the GitHub token secret first — fine-grained PAT with read access to service-demo
kubectl create secret generic github-token \
-n argocd \
--from-literal=token=<your-github-pat>
kubectl apply -f argocd/appsets/ephemeral.yamlThe ApplicationSet polls the service-demo repo for open PRs and automatically creates and prunes an ArgoCD Application per PR.
argocd account generate-token --account admin
# Add as ARGOCD_TOKEN in your service-demo repo secretsCI uses this token for argocd app wait in the preprod test job.
The steps above use a local kind cluster. To run the same setup on a real GKE
cluster instead, see terraform/gke/README.md. It
costs money while running: read the cost note there first.
cd terraform
terraform destroyThis removes the kind cluster and everything on it. There is nothing else to clean up locally.
The deploy-ephemeral, smoke-test, and preprod-tests jobs in service-demo run on a self-hosted runner with access to the kind cluster. Register one with the kind label:
# In service-demo: Settings → Actions → Runners → New self-hosted runner
# After downloading and configuring the runner:
./run.sh --labels kindThe runner needs kubectl pointing at the kind cluster and the argocd CLI installed.
All three stable Applications use ArgoCD's multi-source feature (v2.6+):
- Chart source:
service-demorepo atmain, pathchart/service-demo - Values source: this repo,
values/{env}/service-demo.yaml
This separates chart definition from environment configuration. The CI/CD pipelines only ever write to values files in this repo — they never modify the chart. The ephemeral ApplicationSet uses a single source (the chart at the PR's HEAD SHA) with Helm parameters instead.
| Environment | Sync policy | Reason |
|---|---|---|
preprod |
Auto-sync (prune + self-heal) | Needs to stay current with every merge to main |
dev |
Manual | Tracks releases, not snapshots; promoted atomically with prod |
prod |
Manual | A human (or the release workflow) triggers the sync; auto-sync in prod means any commit immediately affects live traffic |
pr-{N} |
Auto-sync (prune + self-heal) | Ephemeral — created and destroyed per PR lifecycle |
| File | Written by | Image tag format |
|---|---|---|
values/preprod/service-demo.yaml |
cd.yml on merge to main |
main-{sha} |
values/dev/service-demo.yaml |
release.yml on manual release |
v{X.Y.Z} |
values/prod/service-demo.yaml |
release.yml on manual release |
v{X.Y.Z} |
values/pr/{N}.yaml |
ci.yml on PR open/update |
pr-{N}-{sha} |
dev and prod are always updated in a single atomic commit by the release workflow — they are guaranteed to be on the same tag.
dev always runs the latest release (v{X.Y.Z}), the same image as production. Ephemeral PR environments set servicesNamespace=dev via the ApplicationSet, which is exposed to the container as SERVICES_NAMESPACE. Any service-to-service call resolves via:
http://{service}.dev.svc.cluster.local
This means PR environments call production-equivalent dependencies rather than stubs or uncontrolled snapshots.
For this tutorial, all four environments run as namespaces on a single kind cluster. The intended production topology is:
- dev cluster —
devnamespace + allpr-{N}ephemeral namespaces, colocated intentionally (in-cluster DNS for service discovery) - preprod cluster — isolated so load and performance tests cannot affect other environments
- prod cluster — separate account, strict IAM, no CI path directly into the cluster
- Replace the
admintoken with a dedicated ArgoCD service account scoped to the minimum required permissions. NetworkPolicyis not configured. Add default-deny policies and explicit allow rules before running anything sensitive.- The GitHub token secret (
github-token) grants read access to theservice-demorepo for the ApplicationSet controller. Use a fine-grained PAT scoped to that repo only. - For production ArgoCD, consider the app-of-apps pattern so ArgoCD manages its own Applications rather than requiring manual
kubectl apply.