EKS + Terraform + Argo CD + Helm: deployment architecture
Server-side solution for a containerised Go GraphQL API (demo-api) plus an OIDC provider (Keycloak) on AWS EKS, in one region. Prepared 2026-10-16.
In one paragraph: Terraform builds the AWS foundation and installs exactly one thing into Kubernetes: Argo CD. From then on, Git is the source of truth for everything inside the cluster. Argo CD pulls a gitops repo and applies it, using Helm only to render YAML. GitHub Actions tests and builds, pushes the image and chart to ECR, and proposes the new version to the gitops repo as a pull request. CI never holds cluster credentials.
Key decisions at a glance
| Decision | Choice | Main alternative(s) | Why |
|---|---|---|---|
| IaC boundary | Terraform for AWS objects; Argo CD for Kubernetes objects (GitOps bridge) | Terraform Helm provider for everything | No chicken-and-egg at plan time; drift is self-healed; one rollback model |
| CD model | Pull: Argo CD syncs from the gitops repo | CI pushes with kubectl/helm | CI holds no cluster credentials; every change is a commit |
| Helm role | Render engine only; the chart is an OCI artifact in ECR | helm install from CI; a chart server | Build once, promote the same chart + image; rollback = git revert |
| Ingress | ALB via the AWS Load Balancer Controller | Gateway API + kgateway / Envoy Gateway / Istio | Managed data plane, ACM/WAF built in, least to operate |
| HTTPS | Domain eksgitops.click + ACM on the ALB | CloudFront default domain; self-signed certificate | Trusted certificate, stable hostnames, US$3/year |
| Pod → AWS auth | EKS Pod Identity | IRSA (OIDC provider + role annotation) | No per-cluster OIDC setup; no ARNs in Helm values |
| Nodes | Managed node group, 2 × t4g.large Graviton, On-Demand | Spot, Karpenter, EKS Auto Mode | Cheapest predictable setup; no interruptions during a demo |
| Secrets | AWS Secrets Manager is the source of truth; External Secrets Operator (ESO) syncs to K8s Secrets; Reloader rolls pods on change | SOPS as the authoring layer (chosen: secrets/*.enc.yaml → CI → Secrets Manager); SOPS decrypted by Argo (rejected); Secrets Store CSI; Vault (enterprise) | Nothing secret in Git or state; rotation needs no commit; every read audited |
| State | RDS Postgres db.t4g.micro, single-AZ, password managed by RDS | In-cluster Postgres; DynamoDB; Aurora Serverless v2 | Stateless API pods can scale; managed DB; ~50¢/day |
| Autoscaling | HPA on CPU, 2–6 pods | KEDA, VPA, Karpenter (nodes) | Built in; only needs metrics-server; easy to demo |
| Observability | OpenTelemetry Go SDK → ADOT collector (Argo) → X-Ray traces, Managed Prometheus metrics, CloudWatch Logs via Fluent Bit; self-hosted Grafana (Argo) as the UI with Keycloak SSO | Amazon Managed Grafana; CloudWatch Observability add-on only; self-hosted Prometheus/Tempo; SaaS | AWS runs the data backends; Grafana works for public guest logins at ~zero cost (AMG needs Identity Center per viewer) |
| Identity | Keycloak in production mode on RDS; realm as code (keycloak-config-cli PostSync Job); SSO for app, Argo CD and Grafana; demo.guest read-only everywhere | Keycloak dev mode with realm import; Cognito | Users persist; Git-reviewed realm changes; one login for every public host |
| App architecture | Go clean architecture (domain / app / adapter) on a shared service kit; two services (demo-api, payments-svc) | One flat package per feature | Business rules testable without I/O; a new service gets logging, tracing, auth and health for free |
| Scope (2026-10-16) | dev environment only; Argo CD UI public with Keycloak SSO; Terraform from the laptop first, IaC pipeline after | dev + prod namespaces; Argo via port-forward; CI-run Terraform from day 1 | Fastest path to a working, showable demo; the pipeline follows once the build is proven |
Colour key, used in every diagram and table: the colour tells you who creates or configures the thing.
1. Ownership map: what runs where, and who owns it
How to read it:
- Purple things exist because
terraform applyran. They change only through a Terraform change. - Orange things exist because a YAML file is in the gitops repo; Argo CD applies it and keeps re-applying it. Edit the cluster by hand and Argo reverts it.
- Pink things nobody declares directly: a controller creates them from a Kubernetes object.
- An Ingress becomes an ALB.
- An Ingress host becomes a Route 53 record.
- An ExternalSecret becomes a Secret.
- The EKS control plane (API server, etcd) is run by AWS. Terraform only chooses its settings.
- Not drawn: the ADOT collector and Fluent Bit (orange, installed by Argo in an
observabilitynamespace) that ship traces, metrics and logs to the AWS-managed backends. See App & observability.
2. Which file configures what
Two repos. The app repo holds code, the chart, CI and Terraform. The gitops repo holds the desired state of the cluster, i.e. what Argo CD applies.
| File | Read by | Creates / configures |
|---|---|---|
| app repo | ||
infra/bootstrap/*.tf | terraform | S3 state bucket, ECR repos demo-api and charts/demo-api (immutable tags), GitHub OIDC provider + ci-ecr-push role. ACM certificate for eksgitops.click + *.eksgitops.click, DNS-validated in the hosted zone the registration created (aws_acm_certificate + validation aws_route53_record + aws_acm_certificate_validation); auto-renews. Applied once; stays. |
infra/eks/vpc.tf | terraform | VPC, 2 public + 2 private subnets. Public subnets tagged kubernetes.io/role/elb=1 so the LB controller knows where to put the ALB. |
infra/eks/eks.tf | terraform | Cluster, managed node group, EKS managed add-ons, access entries (who may use kubectl). |
infra/eks/pod-identity.tf | terraform | An IAM role + Pod Identity association per controller: LB controller, external-dns, external-secrets. |
infra/eks/secrets.tf | terraform | Empty Secrets Manager entries (demo/demo-api/*, Keycloak admin) + IAM read access for ESO. Never the values. |
infra/eks/rds.tf | terraform | RDS Postgres db.t4g.micro in the private subnets, security group open to the cluster only, manage_master_user_password = true. |
Makefile → secrets-seed | you | Reads a local, git-ignored .env and runs aws secretsmanager put-secret-value. The only way values enter AWS. |
infra/argocd/*.tf (separate root, applied after infra/eks) | terraform (helm + kubernetes providers) | Argo CD release, the root Application, and the bridge cluster Secret (Terraform outputs as annotations). |
infra/eks/observability.tf | terraform | Managed Prometheus workspace, CloudWatch log groups (7-day retention), SNS alert topic, Pod Identity roles for the collector, Fluent Bit and Grafana (read AMP, X-Ray, CloudWatch). |
Dockerfile | CI | The arm64 image demo-api:<git sha>. |
deploy/helm/demo-api/ | CI packages · Argo CD renders | Templates for Deployment, Service, Ingress, ConfigMap, hpa.yaml, pdb.yaml, externalsecret.yaml, migration Job (PreSync). CI pushes the chart to ECR as charts/demo-api:0.1.N. |
.github/workflows/pr.yaml, release.yaml | GitHub Actions | PR checks; on merge: image + chart to ECR, then a PR to the gitops repo. |
.github/workflows/tf-plan.yaml, tf-apply.yaml, scripts/ci/tf.sh | GitHub Actions | PRs: fmt, validate, tflint, Trivy config and a read-only plan per root (sticky comment, addresses and actions only). Merge: every root planned with plan -detailed-exitcode, the ones with changes applied in order, with the S3 lockfile. Roles ci-terraform-plan (read-only, secret reads denied) and ci-terraform. Auto-apply on since 2026-10-16 (repository variable TF_AUTO_APPLY=true): infrastructure changes go through pull requests and CI applies them on merge; laptop applies are break-glass only, with the variable set to false first. It was false during the parallel build and flipped once the unused IRSA provider was removed and a full plan showed no changes. |
secrets/<env>/<service>.enc.yaml, .sops.yaml, .github/workflows/secrets-sync.yaml | you author · GitHub Actions syncs | Human-authored secrets, sops-encrypted under KMS alias/sops-demo; on merge the sync role writes Secrets Manager demo/<env>/<service>/<key> (unchanged values skipped). Runtime still reads Secrets Manager through ESO. |
| gitops repo | ||
bootstrap/addons.yaml, bootstrap/apps.yaml | Argo CD (root app) | Two child Applications: one for addons/, one for apps/dev/. |
addons/*.yaml | ApplicationSet controller | One Application per add-on (upstream chart + pinned version), incl. reloader.yaml and external-secrets.yaml. Values such as cluster name and VPC id come from the bridge Secret. |
addons/adot-collector.yaml, addons/fluent-bit.yaml | ApplicationSet controller | OpenTelemetry collector (OTLP in → X-Ray + Managed Prometheus out) and the log shipper (node logs → CloudWatch Logs). |
addons/grafana.yaml | ApplicationSet controller | Self-hosted Grafana at grafana.frankbluemind.com: Keycloak SSO, data sources (AMP, X-Ray, CloudWatch) and dashboards provisioned from Git. |
apps/dev/keycloak-config.yaml + config/keycloak/realm.yaml | Argo CD (PostSync Job) | keycloak-config-cli applies the realm as code: clients, roles, groups, users (zhaolin.sun, case.officer, demo.guest), mappers, brute-force protection. |
apps/dev/payments-svc.yaml | Argo CD | The internal second service (ClusterIP only, NetworkPolicy: demo-api only); see the code page. |
apps/dev/demo-api.yaml | Argo CD | Application: which chart version from ECR + which values file. |
values/dev/demo-api.yaml | repo-server (helm template) | Image tag, hostnames, OIDC issuer, replicas: everything that differs per environment. |
| nobody's file: created at runtime | ||
| ALB, target groups, listener rules | LB controller | From every Ingress with className: alb; the certificate is matched to the host automatically. |
| Route 53 A/AAAA records | external-dns | From Ingress hosts. |
| Kubernetes Secrets | external-secrets | From ExternalSecret objects that point at Secrets Manager. |
| Database password + its rotation | RDS | RDS generates the master password, stores it in Secrets Manager and rotates it; ESO picks up each new version. |
| Pod count | HPA | 2–6 replicas from CPU load; Argo ignores /spec/replicas. |
3. Bootstrap: from empty account to running app
- Bootstrap layer. Run once by hand, with local state that is then migrated into the S3 bucket it just created.
- Creates what must outlive any cluster: the state bucket, the ECR repos, the IAM role GitHub Actions assumes through OIDC, and the ACM certificate. Public ACM certificates are free and DNS validation takes minutes, so the certificate survives
eks-down/eks-up. - Cluster layer (
make eks-up), run from your laptop for the initial build as an IAM admin, never as root. Built 2026-10-16: the IaC pipeline (tf-plan.yamlon pull requests,tf-apply.yamlonmain, both via GitHub OIDC roles with trust pinned to the one workflow file) now applies every change underinfra/; theargocdroot is planned only onmainbecause its plan reads in-cluster Secrets. - Builds the VPC, cluster and nodes. Installs the EKS managed add-ons (
vpc-cniandpod-identity-agentbefore the nodes, so pods get IPs and credentials from the first boot) and the IAM roles for the controllers. - Installs Argo CD and the root Application. Writes the bridge: an Argo CD cluster Secret whose labels say which add-ons to enable and whose annotations carry Terraform outputs (cluster name, VPC id, region, domain). From here on Terraform stops.
- Argo CD syncs
bootstrap/in the gitops repo and creates theaddonsandappsApplications (app of apps). - Sync waves order things: add-ons first (wave 0), then Keycloak and demo-api (wave 1).
- Controllers turn Kubernetes objects into AWS resources: an ALB with the ACM certificate, DNS records, Secrets. The app is reachable at
https://app.frankbluemind.com.
Teardown (make eks-down): delete the Ingresses first so the LB controller removes the ALB, then run terraform destroy on infra/eks. Otherwise an orphaned ALB blocks VPC deletion. The bootstrap layer stays.
Terraform: key files
infra/eks/vpc.tf + eks.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "eks-gitops-demo"
cidr = "10.0.0.0/16"
azs = ["ap-southeast-2a", "ap-southeast-2b"]
public_subnets = ["10.0.0.0/20", "10.0.16.0/20"]
private_subnets = ["10.0.32.0/20", "10.0.48.0/20"]
enable_nat_gateway = false # demo: nodes in public subnets, saves ~US$45/month
map_public_ip_on_launch = true
public_subnet_tags = { "kubernetes.io/role/elb" = 1 } # where the ALB may go
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.0"
name = "eks-gitops-demo"
kubernetes_version = "1.37"
endpoint_public_access = true
endpoint_public_access_cidrs = [var.admin_cidr] # only your IP can reach the API
enable_cluster_creator_admin_permissions = true # access entry for the admin running Terraform
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnets
addons = { # EKS managed add-ons (AWS API objects)
vpc-cni = { before_compute = true }
eks-pod-identity-agent = { before_compute = true }
coredns = {}
kube-proxy = {}
}
eks_managed_node_groups = {
default = {
ami_type = "AL2023_ARM_64_STANDARD"
instance_types = ["t4g.large"]
capacity_type = "ON_DEMAND" # idea: SPOT
min_size = 2
max_size = 3
desired_size = 2
}
}
}
infra/eks/pod-identity.tf
module "lbc_pod_identity" {
source = "terraform-aws-modules/eks-pod-identity/aws"
name = "aws-lbc"
attach_aws_lb_controller_policy = true
associations = {
main = {
cluster_name = module.eks.cluster_name
namespace = "kube-system"
service_account = "aws-load-balancer-controller"
}
}
}
# Same pattern for external-dns (scoped to the hosted zone)
# and external-secrets (scoped to the demo/* secrets).
With Pod Identity, the service account needs no role annotation: AWS maps namespace + service account to the role. That is why the Helm values for the controllers stay free of ARNs.
infra/argocd/argocd.tf: Argo CD install + the GitOps bridge (its own root, so the providers target an existing cluster)
resource "helm_release" "argocd" {
name = "argocd"
namespace = "argocd"
create_namespace = true
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
version = var.argocd_chart_version
}
# The bridge: Terraform outputs handed to Argo CD as a cluster Secret.
resource "kubernetes_secret_v1" "in_cluster" {
metadata {
name = "in-cluster"
namespace = "argocd"
labels = {
"argocd.argoproj.io/secret-type" = "cluster"
enable_aws_load_balancer_controller = "true"
enable_external_dns = "true"
enable_external_secrets = "true"
}
annotations = {
cluster_name = module.eks.cluster_name
vpc_id = module.vpc.vpc_id
aws_region = var.region
domain = var.domain
}
}
data = { name = "in-cluster", server = "https://kubernetes.default.svc" }
depends_on = [helm_release.argocd]
}
# Root Application via the argocd-apps chart: no Argo CRDs needed at plan time.
resource "helm_release" "root" {
name = "root"
namespace = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argocd-apps"
values = [file("${path.module}/root-app.yaml")]
depends_on = [helm_release.argocd]
}
Note: why Terraform stops at Argo CD. Installing every add-on with Terraform's Helm provider looks simpler but ages badly:
- The provider needs a running cluster at plan time, which doesn't exist on the first create.
- Drift stays invisible until the next apply.
- Chart values and release state end up in Terraform state, and every plan gets slower.
Argo CD reconciles continuously, shows a diff before each change, and uses the same rollback and audit model as the app. Rule: an AWS API object goes in Terraform; a Kubernetes object goes in Git + Argo CD. The EKS managed add-ons follow the rule: they are configured through the AWS EKS API, so Terraform manages them.
4. Argo CD: the gitops repo
eks-gitops-demo-gitops/
├── bootstrap/ ← the root Application syncs this folder
│ ├── addons.yaml Application → addons/ (sync-wave 0)
│ └── apps.yaml Application → apps/dev/ (sync-wave 1)
├── addons/ one ApplicationSet per add-on
│ ├── aws-load-balancer-controller.yaml
│ ├── external-dns.yaml
│ ├── external-secrets.yaml
│ ├── metrics-server.yaml
│ └── reloader.yaml
├── apps/
│ ├── dev/ demo-api.yaml keycloak.yaml Application: chart + version + values file
│ └── prod/ demo-api.yaml keycloak.yaml (not built: dev only)
└── values/ Helm values, never applied as manifests
├── dev/ demo-api.yaml keycloak.yaml image tag, hosts, issuer
└── prod/ … (not built: dev only)
- App of apps: the root Application's manifests are themselves Applications. After bootstrap, adding a component means adding a file. No Terraform, no
kubectl. - ApplicationSet + cluster generator: each add-on file is a template. It produces an Application for every cluster Secret carrying the matching
enable_*label and fills in values from that Secret's annotations. That is how Terraform outputs reach Helm values without hard-coding. - Sync policy: dev uses
automatedwithprune(delete what was removed from Git) andselfHeal(undo manual edits). Prod is the same, but its files change only through approved pull requests. - Waves gotcha: Argo does not wait for a child Application to become healthy before starting the next wave unless the Application health check is turned back on in
argocd-cm. Add it so apps start after the add-ons are healthy.
addons/aws-load-balancer-controller.yaml (ApplicationSet)
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata: { name: aws-load-balancer-controller, namespace: argocd }
spec:
goTemplate: true
generators:
- clusters:
selector: { matchLabels: { enable_aws_load_balancer_controller: "true" } }
template:
metadata: { name: "aws-lbc-{{.name}}", annotations: { argocd.argoproj.io/sync-wave: "0" } }
spec:
project: default
source:
repoURL: https://aws.github.io/eks-charts
chart: aws-load-balancer-controller
targetRevision: "<pinned version>"
helm:
valuesObject:
clusterName: "{{.metadata.annotations.cluster_name}}"
vpcId: "{{.metadata.annotations.vpc_id}}"
region: "{{.metadata.annotations.aws_region}}"
serviceAccount: { name: aws-load-balancer-controller } # matches the Pod Identity association
destination: { server: "{{.server}}", namespace: kube-system }
syncPolicy:
automated: { prune: true, selfHeal: true }
apps/dev/demo-api.yaml (multi-source Application)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: demo-api-dev, namespace: argocd }
spec:
project: default
sources:
- repoURL: <account>.dkr.ecr.ap-southeast-2.amazonaws.com/charts # Helm repo = ECR (OCI)
chart: demo-api
targetRevision: 0.1.42 # bumped by the CI bot PR
helm:
valueFiles: [$values/values/dev/demo-api.yaml]
- repoURL: https://github.com/<you>/eks-gitops-demo-gitops # values come from Git
targetRevision: main
ref: values
destination: { server: https://kubernetes.default.svc, namespace: demo }
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [CreateNamespace=true]
5. Helm: render engine, ECR as the chart repo
Note: yes, Helm is only the render engine. The repo-server runs helm template chart --values …, and the application-controller diffs that YAML against the live cluster and applies it. Argo never runs helm install. The consequences:
- There are no Helm release records, so
helm listis empty andhelm rollbackdoes nothing. Rollback = revert the commit in the gitops repo. - Helm hooks are mapped to Argo phases, e.g.
pre-upgrade→ PreSync andpost-install→ PostSync. - Helm's
lookupreturns nothing, so charts must not depend on it. - Argo labels what it applied and can prune leftovers, which a plain
helm template | kubectl applycannot.
The chart is a versioned artifact, like the image.
- CI packages
deploy/helm/demo-apionce per merge (0.1.N) and pushes it to ECR as an OCI artifact, next to the image. No separate chart server is needed. - An environment is chart version + values file, so prod runs the exact bytes that were tested in dev.
- Gotcha: ECR tokens expire every 12 hours. Argo's repository credential is refreshed by an external-secrets
ECRAuthorizationTokengenerator, which uses Pod Identity too.
values/dev/demo-api.yaml
image:
repository: <account>.dkr.ecr.ap-southeast-2.amazonaws.com/demo-api
tag: 3f9c2e1 # git SHA, written by the CI bot; ECR tags are immutable
config:
oidcIssuer: https://auth.frankbluemind.com/realms/demo
oidcAudience: demo-api
env: dev
ingress:
enabled: true
className: alb # handled by the AWS Load Balancer Controller
host: app.frankbluemind.com # external-dns creates the record, ACM cert matched by host
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip # ALB → pod IPs directly (VPC CNI)
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
alb.ingress.kubernetes.io/group.name: demo # app + auth share one ALB
6. Release flow: commit to running pod
go vet, go test -race, golangci-lint, govulncheck, codegen drift check (gqlgen generate + git diff --exit-code), helm lint + kubeconform on the rendered chart, image build + Trivy scan. Nothing is pushed.0.1.<run>. Both are pushed to ECR with short-lived credentials from GitHub OIDC; the role may only push to these repos.targetRevision in apps/dev/demo-api.yaml and image.tag in values/dev/demo-api.yaml. Dev auto-merges. This demo builds dev only; prod would be the same change in the prod files with a required approval (explained, not shown).Note: CI never talks to the cluster. It writes only to ECR and Git. No kubeconfig and no long-lived AWS keys are stored in GitHub. If CI is compromised, the attacker can propose a change but cannot apply it, and every change leaves a commit.
.github/workflows/release.yaml (sketch)
name: release
on:
push:
branches: [main]
permissions:
id-token: write # GitHub OIDC → AWS role, no stored keys
contents: read
jobs:
release:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.ECR_PUSH_ROLE_ARN }}
aws-region: ap-southeast-2
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- run: make test
- run: |
IMG=${{ steps.ecr.outputs.registry }}/demo-api:${GITHUB_SHA::7}
docker build -t "$IMG" . && docker push "$IMG"
- run: |
helm package deploy/helm/demo-api --version "0.1.${GITHUB_RUN_NUMBER}" --app-version "${GITHUB_SHA::7}"
helm push demo-api-*.tgz oci://${{ steps.ecr.outputs.registry }}/charts
- run: scripts/promote.sh dev "0.1.${GITHUB_RUN_NUMBER}" "${GITHUB_SHA::7}" # opens the gitops PR
env:
GH_TOKEN: ${{ secrets.GITOPS_BOT_TOKEN }} # write access to the gitops repo only
7. Runtime: a request, a login, a secret
- Ingress: one ALB, shared through
group.name, serves both hosts. TLS ends at the ALB with the ACM certificate.target-type: ipsends traffic straight to pod IPs; the VPC CNI gives pods real VPC addresses. - Argo CD UI: public at
argocd.frankbluemind.comon the same ALB, login only through Keycloak SSO (OIDC clientargocdin realmdemo; Keycloak groups map to Argo RBAC roles). The local admin account is disabled after bootstrap. - Grafana: public at
grafana.frankbluemind.comon the same ALB, Keycloak SSO; the shareddemo.guestlogin is Viewer. Who can do what: Identity and access. - OIDC issuer rule: the issuer is
https://auth.frankbluemind.com/realms/demo, the same string for the browser and the API pod. The API fetches discovery and JWKS from that public host and caches them. Keycloak runs withKC_HOSTNAMEset and trusts the ALB's forwarded headers. - Secrets: Secrets Manager → ESO → Kubernetes Secret; Git holds only the reference. Details in Secrets.
- Hardening:
- Pods run non-root with a read-only root filesystem and all capabilities dropped.
- Every response carries a Content-Security-Policy (
default-src 'none'for the API; the pages list exactly what they load),Cross-Origin-Opener-Policyand a minimalPermissions-Policy. - The namespace enforces the Pod Security Standard
restricted. - NetworkPolicies (enforced by the VPC CNI) allow ALB → pods and API → Keycloak only.
- The API endpoint is limited to your IP.
- Production differences:
- Nodes in private subnets, with NAT or VPC endpoints.
- Keycloak via its Operator with Postgres on RDS, or a managed IdP (Cognito, Entra ID). Swapping is a configuration change for the API.
- Two or more replicas plus a PodDisruptionBudget.
8. Secrets: store, delivery, rotation
KISS model: AWS Secrets Manager holds the only real copy. Git holds pointers (ExternalSecret objects), never values. The External Secrets Operator (ESO), a CNCF project installed by Argo, copies each value into a Kubernetes Secret and keeps it refreshed. The app reads normal env vars or files and never knows AWS is involved. In an enterprise, swap Secrets Manager for HashiCorp Vault: pods authenticate with Vault's Kubernetes auth method, and ESO or the Vault Secrets Operator delivers.
| Concern | Choice | Notes |
|---|---|---|
| Source of truth | AWS Secrets Manager, demo/demo-api/* | Terraform creates the empty secret + IAM read access, never the value. Values enter via make secrets-seed (local git-ignored .env → put-secret-value), RDS (database password) or a rotation Lambda. |
| Delivery | ESO → K8s Secret → pod | Env var for stable values (client IDs): simple, but read once at start. File mount for rotating values: updates in place within ~1 minute (no subPath, or it never updates); the app must re-read it. |
| Rotation | New version → ESO → Reloader | Env vars need a restart: Stakater Reloader (one annotation) triggers a rolling restart, zero downtime with 2+ replicas and readiness probes. Third-party credentials rotate in two phases (add new → switch → revoke old) so pods never hold an invalid value. |
| Security / ops | Least privilege + audit | IAM scoped to demo/demo-api/* via Pod Identity; KMS encryption; CloudTrail logs every read; Kubernetes RBAC limits who reads Secrets; EKS envelope-encrypts Secrets with KMS. Cost: US$0.40/secret/month (SSM Parameter Store standard tier is a free alternative). |
| Alternative | Why not chosen (for this demo) |
|---|---|
| Secrets Store CSI driver (AWS / Vault provider) | Mounts files straight from the store, can skip the K8s Secret entirely. But env vars still need its sync feature, and pod start depends on the store. Good when secrets must never touch etcd. |
| Vault Secrets Operator / Vault Agent | The enterprise answer: dynamic secrets (per-pod DB credentials with leases), built-in restarts. Needs Vault. |
| App calls the SDK directly | Most dynamic, but couples code to one store and is harder to test. |
| SOPS (encrypted values in Git) | Chosen for authoring only. Human-supplied secrets are edited as secrets/<env>/*.enc.yaml (keys readable, values ciphertext under a per-environment KMS key), reviewed in a PR, and synced to Secrets Manager by CI (secrets-sync.yaml). Decrypting in Argo was rejected: rotation would need a commit, the repo-server would hold KMS decrypt rights and cache plaintext, and a leaked key would expose Git history. Generated secrets (RDS password, client secrets) never enter Git. |
Built (2026-10-16): the partner API key, delivered as env and as a hot-reloaded file. Rotated live on EKS the same day: the running pods reloaded the file 74–90 s after the merge, Reloader's roll was done at 3 min 03 s, and no request failed. secrets/dev/demo-api.enc.yaml (sops) → secrets-sync.yaml on merge → Secrets Manager demo/dev/demo-api/partner-api → ESO → two Kubernetes Secrets from the same source:
demo-api-partner-file(refresh 30s), mounted as a whole volume:internal/platform/secretfilere-reads it every 10s and swaps the value in place, so the running pods logsecret loaded … "source":"file" … "reload":truewithout a restart.demo-api-partner-env(refresh 3m) feedsPARTNER_API_KEY; only this Secret is in the Reloader annotation, so Reloader rolls the pods and the new ones log"source":"env"with the new fingerprint.
Only fingerprints (first 8 hex characters of the SHA-256) are logged or exported (secret_info{name,source,fingerprint}, secret_reloads_total); the Grafana dashboard shows each pod's fingerprint. A secrets-only merge never redeploys (release.yaml ignores secrets/**). Runbook: scripts/rotate-secret-demo.sh (README, which also gives the measured timings).
ExternalSecret + Deployment as built (env var, file mount, Reloader)
# gitops charts/demo-secrets: two Secrets from the same source, refreshed at different rates
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: demo-api-partner-file, namespace: demo }
spec:
refreshInterval: 30s
secretStoreRef: { kind: ClusterSecretStore, name: aws-secrets-manager }
target: { name: demo-api-partner-file } # K8s Secret created and owned by ESO
data:
- secretKey: api_key
remoteRef: { key: demo/dev/demo-api/partner-api, property: api_key }
# demo-api-partner-env: the same, refreshInterval 3m
---
# demo-api Deployment (excerpt, rendered from the Helm chart, values partnerKey.*)
metadata:
annotations:
secret.reloader.stakater.com/reload: "demo-api-db,demo-api-payments,demo-api-partner-env" # NOT the file Secret
spec:
template:
spec:
securityContext: { fsGroup: 65532 } # mounted files belong to the app's group
containers:
- name: api
env:
- name: PARTNER_API_KEY # fixed until the pod is replaced
valueFrom: { secretKeyRef: { name: demo-api-partner-env, key: api_key } }
- name: PARTNER_KEY_FILE # re-read in place
value: /var/run/secrets/partner/api_key
volumeMounts: # whole volume, never subPath (subPath never updates)
- { name: partner-key, mountPath: /var/run/secrets/partner, readOnly: true }
volumes:
- name: partner-key
secret: { secretName: demo-api-partner-file, defaultMode: 0440 }
9. State and scaling
Why: to scale, the API must hold no state in the pod. Today it keeps data in memory with 1 replica, so 2 pods would show different data. Auth is already stateless: JWTs are validated locally against cached JWKS, and the SPA holds the session. Moving data to a shared database makes every pod interchangeable.
| Decision | Options | Choice and why |
|---|---|---|
| State |
| Managed and realistic for ~50¢/day. Terraform infra/eks/rds.tf creates it and destroys it with the cluster. manage_master_user_password = true: RDS generates and rotates the password in Secrets Manager, ESO delivers it, so it shows the full secrets flow with no custom rotation code. Migrations + seed run as an Argo PreSync Job. App change: a Postgres store (sqlc + pgx) replaces the in-memory store; local dev gets a Postgres container in docker-compose. |
| Autoscaling |
| Built in, needs only metrics-server (already an add-on). Min 2, max 6, 60% CPU, scale-down window 120 s for the demo. Gotchas: CPU % is relative to requests (chart sets 50m); readiness probes + a PodDisruptionBudget keep scaling safe; remove replicas from Git or set Argo ignoreDifferences on /spec/replicas, or Argo fights the HPA. Upgrade path: KEDA on requests/s or p95 once Prometheus exists (GraphQL query cost varies, so CPU is only a proxy); Karpenter if pods outgrow 2 nodes. |
Scaling demo:
- Generate load with
heyork6againsthttps://app.frankbluemind.com/graphqlusing a real token. - Watch
kubectl get hpa,pods -w: replicas go 2 → 6; the ALB registers new pod IPs automatically (target-type: ip). - Run the same query repeatedly: identical results from every pod, proving shared state.
- Stop the load: after the cool-down it scales back to 2.
Connection sizing: 6 pods × pool of 10 = 60 connections, under t4g.micro's ~80 max_connections. Beyond that, add RDS Proxy. 6 small pods fit easily on 2 × t4g.large, so node scaling isn't needed for the demo.
hpa.yaml + Argo ignoreDifferences
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: demo-api }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: demo-api }
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
behavior:
scaleDown: { stabilizationWindowSeconds: 120 } # default 300 s; shortened for the demo
---
# in apps/dev/demo-api.yaml (Argo Application)
spec:
ignoreDifferences:
- { group: apps, kind: Deployment, jsonPointers: [/spec/replicas] }
Acceptance checks (pass/fail, written before the build):
- A load test scales
demo-apifrom 2 to 6 pods, and back to 2 after the load stops. - The same query returns identical data from every pod.
- Rotating
demo/demo-api/partner-apiupdates the K8s Secret within the refresh interval, and Reloader rolls the pods with no failed requests. - An RDS password rotation is picked up by the app with no manual step.
10. Account readiness and cost
| Check | Status |
|---|---|
| Root MFA · budget alert | Done (alert at US$20/month). Open: raise it to ~US$60/month before the build. |
| vCPU quota | 16 On-Demand, 32 Spot. The demo uses 4 (2 × t4g.large); scaling to 3 nodes uses 6. |
| Tooling | terraform, helm, eksctl, kind, kubectl installed. Upgrade kubectl to within one minor version of the cluster. |
| Open | Stop using root for the CLI: create an IAM Identity Center admin and run Terraform as that. |
Domain eksgitops.click | Active (registered 2026-10-16 via the Route 53 Domains CLI; US$3/year, auto-renew, privacy protection on, expires 2027-10-16). Hosted zone created automatically; registrar nameservers match it. Open: click the contact-email verification link (by ~2026-10-31) and wait for the .click registry to publish the delegation. Until DNS is live, kubectl port-forward still works: localhost counts as a secure context for the PKCE login. |
Approximate running cost: control plane US$0.10/h + 2 × t4g.large + ALB + RDS db.t4g.micro (~US$0.5/day) ≈ US$5.5–8.5/day; Grafana is one small in-cluster pod. The cluster layer is destroyed between demos; a 4-hour session ≈ US$1–1.5. The bootstrap layer costs cents.
11. Decisions
| Decision | Context | Options | Choice and why | Revisit when |
|---|---|---|---|---|
| Ingress | Something must route HTTPS traffic to demo-api and Keycloak.
Naming: "ALB Ingress Controller" is the old name (v1, |
|
Is ALB current best practice? It is the most common AWS-native default: AWS runs the data plane, ACM and WAF are built in, and it is the least to operate. For routing APIs, the industry is moving to Gateway API: ingress-nginx is retired, and kgateway (formerly Gloo, a CNCF project built on Envoy) adds API-gateway features such as rate limits, JWT validation and weighted canaries. A common enterprise pattern puts an NLB or ALB in front of an in-cluster Gateway API gateway. The LB controller also supports Gateway API now (HTTPRoute → ALB); it is recent, so verify maturity first.For this demo: ALB. No proxy pods, so it fits on 2 nodes. |
We need rate limiting, JWT validation at the edge, weighted canaries, or portability across clouds. |
| HTTPS | The PKCE login needs a secure context (crypto.subtle), and Keycloak's default sslRequired=external refuses plain HTTP. No CA issues certificates for *.elb.amazonaws.com. |
|
Domain eksgitops.click at US$3/year: the fewest moving parts, stable hostnames, a free ACM certificate (bootstrap layer, auto-renews), and the same shape as production. kubectl port-forward remains the zero-cost local option.
Where TLS ends: at the ALB; ACM certificates cannot be mounted into pods. ALB → pod is HTTP inside the VPC. For end-to-end TLS in production, use HTTPS to the pods or a service mesh. If TLS ended in-cluster instead (e.g. a Gateway API gateway such as kgateway), use cert-manager + Let's Encrypt, with DNS-01 validation for the wildcard. |
The domain lapses, or a no-domain throwaway environment is needed (use CloudFront then). |
12. Ideas (not in the first build)
- Security scanning, enterprise-grade: PR gates for code (golangci-lint + gosec, govulncheck, CodeQL), secrets (gitleaks), IaC (tflint + Trivy config or Checkov on
infra/), Kubernetes (kubeconform + Kyverno/conftest policies on the rendered chart) and images (Trivy, SBOM, cosign signature). In-cluster: Kyverno admission that verifies image signatures and enforces restricted pods, NetworkPolicies, GuardDuty EKS protection, kube-bench CIS checks, Security Hub. - Spot nodes:
capacity_type = "SPOT"with 2+ instance types (t4g.large, m7g.large) for about 60–70% off node cost. Managed node groups drain a Spot node automatically when AWS reclaims it. Keep On-Demand during a live demo. - Karpenter for node autoscaling, and KEDA on requests/s (see the autoscaling decision): Terraform creates Karpenter's IAM role and interruption queue, Argo installs the charts.
- EKS Auto Mode (AWS runs CNI, DNS, the load balancer controller, storage and Karpenter) and AWS-managed Argo CD (EKS Capabilities). Less to operate, for a fee; self-managed is chosen here to show every moving part.
- Argo Rollouts canary releases. Observability is now part of the plan: see App & observability.