eks-gitops-demo Ownership mapWhich file configures whatBootstrapArgo CDHelmRelease flowRuntimeSecretsState & scalingReadinessDecisionsApp & observability →Code architecture →

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

DecisionChoiceMain alternative(s)Why
IaC boundaryTerraform for AWS objects; Argo CD for Kubernetes objects (GitOps bridge)Terraform Helm provider for everythingNo chicken-and-egg at plan time; drift is self-healed; one rollback model
CD modelPull: Argo CD syncs from the gitops repoCI pushes with kubectl/helmCI holds no cluster credentials; every change is a commit
Helm roleRender engine only; the chart is an OCI artifact in ECRhelm install from CI; a chart serverBuild once, promote the same chart + image; rollback = git revert
IngressALB via the AWS Load Balancer ControllerGateway API + kgateway / Envoy Gateway / IstioManaged data plane, ACM/WAF built in, least to operate
HTTPSDomain eksgitops.click + ACM on the ALBCloudFront default domain; self-signed certificateTrusted certificate, stable hostnames, US$3/year
Pod → AWS authEKS Pod IdentityIRSA (OIDC provider + role annotation)No per-cluster OIDC setup; no ARNs in Helm values
NodesManaged node group, 2 × t4g.large Graviton, On-DemandSpot, Karpenter, EKS Auto ModeCheapest predictable setup; no interruptions during a demo
SecretsAWS Secrets Manager is the source of truth; External Secrets Operator (ESO) syncs to K8s Secrets; Reloader rolls pods on changeSOPS 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
StateRDS Postgres db.t4g.micro, single-AZ, password managed by RDSIn-cluster Postgres; DynamoDB; Aurora Serverless v2Stateless API pods can scale; managed DB; ~50¢/day
AutoscalingHPA on CPU, 2–6 podsKEDA, VPA, Karpenter (nodes)Built in; only needs metrics-server; easy to demo
ObservabilityOpenTelemetry 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 SSOAmazon Managed Grafana; CloudWatch Observability add-on only; self-hosted Prometheus/Tempo; SaaSAWS runs the data backends; Grafana works for public guest logins at ~zero cost (AMG needs Identity Center per viewer)
IdentityKeycloak in production mode on RDS; realm as code (keycloak-config-cli PostSync Job); SSO for app, Argo CD and Grafana; demo.guest read-only everywhereKeycloak dev mode with realm import; CognitoUsers persist; Git-reviewed realm changes; one login for every public host
App architectureGo clean architecture (domain / app / adapter) on a shared service kit; two services (demo-api, payments-svc)One flat package per featureBusiness 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 afterdev + prod namespaces; Argo via port-forward; CI-run Terraform from day 1Fastest 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.

TerraformArgo CD (from Git)Created at runtime by a controllerGitHub Actions (CI)Artifact / Git storeYou

1. Ownership map: what runs where, and who owns it

AWS account containing a Terraform-built VPC and EKS cluster. Inside the cluster: platform add-ons installed by Argo CD, EKS managed add-ons and Argo CD installed by Terraform, and the demo namespace applied by Argo CD. AWS account · ap-southeast-2 S3 bucketTerraform state, native locking IAM rolesCI (GitHub OIDC) · Pod Identity Application Load Balancercreated by the LB controller Route 53 zonerecords written by external-dns Secrets Managerread by external-secrets ACM cert *.eksgitops.clickbootstrap layer · auto-renews ECR repositoriesimage + chart pushed by CI RDS Postgresprivate subnet · managed password X-Ray · Prometheus · LogsAWS-managed telemetry backends VPC2 AZs · public + private subnets · Terraform EKS clustercontrol plane operated by AWS · version, endpoint, access entries set by Terraform Platform add-onsArgo CD · ApplicationSets aws-lb-controllerIngress → ALB external-dnsIngress host → DNS record external-secretsAWS secret → K8s Secret metrics-serverCPU/memory for HPA reloaderSecret changed → roll pods EKS managed add-onsTerraform · kube-system vpc-cnipod IPs, NetworkPolicy corednscluster DNS kube-proxyService routing pod-identity-agentpod → IAM role credentials Argo CDTerraform helm_release · argocd argocd-serverUI, API, SSO repo-servergit clone + helm template application-controllerdiff → apply → self-heal applicationset-controllertemplates → Applications root Applicationapp of apps (bootstrap) demo namespaceArgo CD · rendered by Helm demo-api Deploymentimage from ECR, probes Service + IngressALB rule app.frankbluemind.com ConfigMapOIDC issuer, audience Secretsynced by external-secrets Keycloakauth.frankbluemind.com Managed node group2 × t4g.large Graviton · Amazon Linux 2023 · kubelet pulls images from ECR · Terraform

How to read it:

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.

FileRead byCreates / configures
app repo
infra/bootstrap/*.tfterraformS3 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.tfterraformVPC, 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.tfterraformCluster, managed node group, EKS managed add-ons, access entries (who may use kubectl).
infra/eks/pod-identity.tfterraformAn IAM role + Pod Identity association per controller: LB controller, external-dns, external-secrets.
infra/eks/secrets.tfterraformEmpty Secrets Manager entries (demo/demo-api/*, Keycloak admin) + IAM read access for ESO. Never the values.
infra/eks/rds.tfterraformRDS Postgres db.t4g.micro in the private subnets, security group open to the cluster only, manage_master_user_password = true.
Makefile → secrets-seedyouReads 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.tfterraformManaged 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).
DockerfileCIThe arm64 image demo-api:<git sha>.
deploy/helm/demo-api/CI packages · Argo CD rendersTemplates 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.yamlGitHub ActionsPR 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.shGitHub ActionsPRs: 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.yamlyou author · GitHub Actions syncsHuman-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.yamlArgo CD (root app)Two child Applications: one for addons/, one for apps/dev/.
addons/*.yamlApplicationSet controllerOne 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.yamlApplicationSet controllerOpenTelemetry collector (OTLP in → X-Ray + Managed Prometheus out) and the log shipper (node logs → CloudWatch Logs).
addons/grafana.yamlApplicationSet controllerSelf-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.yamlArgo 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.yamlArgo CDThe internal second service (ClusterIP only, NetworkPolicy: demo-api only); see the code page.
apps/dev/demo-api.yamlArgo CDApplication: which chart version from ECR + which values file.
values/dev/demo-api.yamlrepo-server (helm template)Image tag, hostnames, OIDC issuer, replicas: everything that differs per environment.
nobody's file: created at runtime
ALB, target groups, listener rulesLB controllerFrom every Ingress with className: alb; the certificate is matched to the host automatically.
Route 53 A/AAAA recordsexternal-dnsFrom Ingress hosts.
Kubernetes Secretsexternal-secretsFrom ExternalSecret objects that point at Secrets Manager.
Database password + its rotationRDSRDS generates the master password, stores it in Secrets Manager and rotates it; ESO picks up each new version.
Pod countHPA2–6 replicas from CPU load; Argo ignores /spec/replicas.

3. Bootstrap: from empty account to running app

Swimlane of 8 bootstrap steps across You, Terraform, Argo CD and controllers. You Terraform Argo CD Controllers ① terraform applyinfra/bootstrap ② S3, ECR, ACMCI role (OIDC) ③ terraform applyinfra/eks ④ VPC + EKSnodes, add-ons, IAM ⑤ Argo CD (helm)root app + bridge ⑥ root app syncs→ addons, apps ⑦ Argo appliesadd-ons, then apps ⑧ controllers actALB, DNS, Secrets
  1. Bootstrap layer. Run once by hand, with local state that is then migrated into the S3 bucket it just created.
  2. 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.
  3. 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.yaml on pull requests, tf-apply.yaml on main, both via GitHub OIDC roles with trust pinned to the one workflow file) now applies every change under infra/; the argocd root is planned only on main because its plan reads in-cluster Secrets.
  4. Builds the VPC, cluster and nodes. Installs the EKS managed add-ons (vpc-cni and pod-identity-agent before the nodes, so pods get IPs and credentials from the first boot) and the IAM roles for the controllers.
  5. 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.
  6. Argo CD syncs bootstrap/ in the gitops repo and creates the addons and apps Applications (app of apps).
  7. Sync waves order things: add-ons first (wave 0), then Keycloak and demo-api (wave 1).
  8. 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:

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)
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:

The chart is a versioned artifact, like the image.

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

Swimlane of 8 release steps across Developer, GitHub Actions, artifacts, Argo CD and the cluster. Developer GitHub Actions ECR · gitops Argo CD Cluster ① open PRapp repo ② PR checkstest · lint · scan ③ review, mergeto main ④ build, signimage + chart ⑤ push to ECR:3f9c2e1, 0.1.42 ⑥ gitops PRbump dev files ⑦ sync devtemplate → apply ⑧ rolloutprobes, smoke test
② PR checks
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.
④–⑤ Build
arm64 image tagged with the git SHA, plus an SBOM and a cosign keyless signature. Chart packaged as 0.1.<run>. Both are pushed to ECR with short-lived credentials from GitHub OIDC; the role may only push to these repos.
⑥ Promote
A bot opens a PR in the gitops repo changing two lines: 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).
⑦–⑧ Deploy
Argo CD notices the commit (polling, or a webhook for speed), renders, diffs and applies. Kubernetes rolls the pods; readiness probes keep traffic on the old pods until the new ones answer. A PostSync Job runs the end-to-end smoke checks.
Rollback
Revert the promotion commit; Argo syncs back to the previous chart and tag.

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

Browser resolves via Route 53, connects over HTTPS to the ALB, which routes by host to demo-api or Keycloak pods. Nodes pull images from ECR; external-secrets syncs from Secrets Manager. BrowserSPA, PKCE login Route 53records by external-dns ALB · HTTPS 443ACM cert · routes by host demo-api podsapp.frankbluemind.com Keycloak podauth.frankbluemind.com ECRimage pulled by the node Secrets Manager→ external-secrets → Secret ① resolve ② HTTPS JWKS, cached

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.

Rotation flow: a new version in Secrets Manager is synced by ESO into a Kubernetes Secret; mounted files update in place, while env-var consumers get a zero-downtime rolling restart from Reloader. ① new valuesecrets-seed · RDS · Lambda ② Secrets ManagerAWSPENDING → AWSCURRENT ③ ESO refresh (5 min)K8s Secret updated ④a file mount updatesapp re-reads · no restart ④b Reloader sees changeenv-var consumers ⑤ rolling restartreadiness gate · no downtime
ConcernChoiceNotes
Source of truthAWS 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.
DeliveryESO → K8s Secret → podEnv 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.
RotationNew version → ESO → ReloaderEnv 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 / opsLeast privilege + auditIAM 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).
AlternativeWhy 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 AgentThe enterprise answer: dynamic secrets (per-pod DB credentials with leases), built-in restarts. Needs Vault.
App calls the SDK directlyMost 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:

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.

DecisionOptionsChoice and why
State
  • chosen RDS Postgres db.t4g.micro, single-AZ, private subnets (~US$0.02/h)
  • Postgres pod in the cluster: free, but data dies with the pod; weaker story
  • DynamoDB: serverless, but the domain is relational
  • Aurora Serverless v2: scales to zero, but cold starts and higher cost
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
  • chosen HPA (autoscaling/v2) on CPU
  • KEDA: scales on events or Prometheus queries (requests/s, p95, queue depth), down to zero; manages an HPA for you
  • VPA: right-sizes requests; don't combine with HPA on the same CPU metric
  • Karpenter / Cluster Autoscaler: adds nodes when pods don't fit; complements HPA
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:

  1. Generate load with hey or k6 against https://app.frankbluemind.com/graphql using a real token.
  2. Watch kubectl get hpa,pods -w: replicas go 2 → 6; the ALB registers new pod IPs automatically (target-type: ip).
  3. Run the same query repeatedly: identical results from every pod, proving shared state.
  4. 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):

10. Account readiness and cost

CheckStatus
Root MFA · budget alertDone (alert at US$20/month). Open: raise it to ~US$60/month before the build.
vCPU quota16 On-Demand, 32 Spot. The demo uses 4 (2 × t4g.large); scaling to 3 nodes uses 6.
Toolingterraform, helm, eksctl, kind, kubectl installed. Upgrade kubectl to within one minor version of the cluster.
OpenStop using root for the CLI: create an IAM Identity Center admin and run Terraform as that.
Domain eksgitops.clickActive (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

DecisionContextOptionsChoice and whyRevisit when
IngressSomething must route HTTPS traffic to demo-api and Keycloak.

Naming: "ALB Ingress Controller" is the old name (v1, kubernetes-sigs/aws-alb-ingress-controller). Since its renaming around 2020 it is the AWS Load Balancer Controller (v2+), which also provisions NLBs for Service type LoadBalancer.

  • chosen ALB via the AWS Load Balancer Controller (Ingress)
  • Gateway API with an in-cluster Envoy gateway (kgateway, Envoy Gateway, Istio) behind an NLB
  • ALB in front of an in-cluster gateway
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.
HTTPSThe 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.
  • chosen Real domain + ACM certificate on the ALB
  • CloudFront default *.cloudfront.net: no domain, but needs a Terraform-owned ALB + TargetGroupBinding, and the URL changes on every rebuild
  • Self-signed certificate on the ALB: browser warning, and the API pod must trust the CA
  • Plain HTTP: breaks PKCE and Keycloak login
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)