5 Projects to Land an
Infra/DevOps/Platform Role

A practical execution guide. Every phase maps to your learning notes. Build the Receipt API across five projects, adding depth each time. LLM coding agents handle the boilerplate. You handle the decisions.

One application. Five projects. 55\u201380 hours.
GitOps \u2192 Platform Engineering \u2192 Observability \u2192 Multi-Environment \u2192 Custom Operator. Every phase has \u201cPause & Predict\u201d questions you must answer before writing code.

How to use this guide

The rule: understand before you automate

Each phase lists exactly which sections of your notes to re-read before you write a single line. The LLM agent can scaffold YAML, HCL, and Go \u2014 but you must predict what that YAML will make the system do. If you cannot explain the plan before applying it, you are not learning; you are typing.

The knowledge map

Note filePathNickname in this guide
AWS & DevOps A to Zaws-devops-merged.mdAWS \xa7N
Docker \u2014 First Principlesdocker-merged.mdDocker \xa7N
Terraform \u2014 Understand the Machineterraform-merged.mdTF \xa7N
Learn Kubernetes \u2014 First Principleslearn kubernetis/merged.mdK8s \xa7N

What LLM agents should do

  1. Scaffold boilerplate: Dockerfiles, Terraform resource stubs, Helm chart skeletons, GitHub Actions workflow YAML, Go project structure.
  2. Fix syntax: correct HCL indentation, YAML schema errors, Go compilation.
  3. Generate repetitive blocks: \u201cwrite 10 similar IAM policies for these roles.\u201d
  4. Explain errors: paste the terminal output, ask it to trace the failure.

What you must do

  1. Read the referenced note sections before each phase.
  2. Answer every Pause & Predict question aloud before proceeding.
  3. Choose architecture boundaries: which resources share state, which modules are reusable, where to draw the GitOps/terraform ownership line.
  4. Review every plan before apply. The agent writes HCL; you approve + create / ~ update / -/+ destroy then create.
  5. Break and repair each phase twice before moving on.

AWS cost expectations

All five projects fit comfortably within the AWS free tier for the first month if you:

Estimated worst-case cost: $40\u201380/month with EKS running 24/7. With disciplined tear-down: $15\u201325/month.

Time budget

ProjectEstimated hoursCan parallelize with
P1 \xb7 GitOps Platform15\u201320Nothing; it is the foundation
P2 \xb7 Platform-as-Product10\u201315Build on P1 infrastructure
P3 \xb7 Observability10\u201315Deploy on P1 cluster alongside Receipt API
P4 \xb7 Multi-Environment8\u201312Reuses P1 CI patterns, adds ECS
P5 \xb7 K8s Operator12\u201318Runs on same P1 cluster

Total: 55\u201380 hours. At 10 hours/week: 6\u20138 weeks.


The Receipt API \u2014 one application, five projects

POST /receipts
  accepts a PDF receipt
  stores it in S3
  returns an object ID

GET /health/ready
  returns 200 when this copy can receive traffic

Every project deploys, observes, or extends this same API. You will write it once and understand it deeply. The code itself is deliberately trivial \u2014 the infrastructure surrounding it is where the learning happens.

Choose a language you already know. A minimal HTTP server (Go net/http, Python http.server, Node.js express, Rust axum) that:

  1. Reads the request body as bytes.
  2. Stores it in S3 using the AWS SDK.
  3. Logs a structured JSON line with request-id, object-key, duration-ms, status.
  4. Responds with {"id": "abc123"}.

Use Pod Identity / IRSA for credentials. The SDK default credential chain picks them up automatically. Read AWS \xa71 (IAM) again before deciding which role gets which permission.


Project 1

GitOps Deployment Platform

Goal: A commit to the receipt-api source repo becomes a healthy pod on EKS without a single kubectl command from a human. Time: 15–20 hours | Cost: EKS control plane + 2 t4g.small nodes ≈ $0.11/hr

GitHub Actions → ECR → GitOps repository → Argo CD → Helm → EKS
                                                     ↑
Route 53 → ALB → healthy pods → S3                 Terraform builds the platform
                         ↓
                     CloudWatch

Phase 1.1 — Draw before building

Duration: 30 minutes — No code

Notes to re-read

What to build

On paper (or Mermaid in a scratch file), draw these two diagrams:

Diagram A — the runtime data path:

flowchart LR
    U["User"] -->|"DNS resolution
Route 53, UDP 53"| ALB["ALB
internet-facing"]
    ALB -->|"HTTPS :443
TLS terminated at ALB"| POD["Pod
receipt-api"]
    POD -->|"Pod Identity
IRSA, STS"| S3["S3 Bucket
receipts"]
    

Diagram B — the change delivery path:

flowchart LR
    GIT["git push
to receipt-api repo"] -->|"OIDC: GitHub Actions
IAM role"| CI["CI
Build & Push"]
    CI -->|"docker push
digest"| ECR["ECR
immutable image"]
    ECR -->|"PR updates digest
in values file"| PR["GitOps PR
receipt-deploy repo"]
    PR -->|"Argo CD watches Git
reconciliation loop"| ARGO["Argo CD
Application"]
    ARGO -->|"Helm template
then kubectl apply"| EKS["EKS
receipt namespace"]
    
Pause & Predict
  1. Which AWS component does the application authentication (human user)?
  2. Which AWS component does the infrastructure authentication (pod → S3)?
  3. Why are they different?

Phase 1.2 — Receipt API (containerised)

Duration: 2–3 hours — Docker §1–6

Notes to re-read

What to build

receipt-api/Dockerfile — a production-minded image:

  1. Multi-stage if your language benefits from it (Go, Rust: yes; Python, Node: less so).
  2. Final stage runs as non-root user (UID 10001).
  3. HEALTHCHECK probes /health/ready.
  4. Exec-form CMD.
  5. No secrets baked into layers.
  6. Uses a trusted base image (not :latest floating reference).

receipt-api/app.py (or equivalent) — the API itself:

Decision checkpoint

DecisionWhy it matters
Which base image?Attack surface, update cadence. Re-read Docker §10 (base image policy).
Build-time vs runtime config?Secrets and environment-specific endpoints must NOT be in the image.
Health check end-to-end?Calls the real HTTP endpoint, not just pgrep.
Non-root UID matching?Must align with volume permissions later (Project 3).
Pause & Predict
  1. Your Dockerfile copies requirements.txt before app.py. Why does the order matter for build cache? (Docker §5)
  2. You set USER 10001. Will the container be able to bind to port 80? Why not? (Docker §2 — capabilities)

Phase 1.3 — S3 bucket and IAM roles (Terraform Foundation)

Duration: 3–4 hours — TF §Expedition One–Two, AWS §1, §4

Notes to re-read

What to build

A standalone Terraform root terraform/foundation/ that creates:

ResourceThis module answers...
aws_s3_bucket.receiptsWhere do receipt PDFs survive pod replacement?
aws_s3_bucket_versioningCan we recover from accidental overwrite?
aws_s3_bucket_public_access_blockIs the bucket definitely private?
aws_s3_bucket_server_side_encryption_configurationAre objects encrypted at rest by default?
aws_iam_policy.receipt_s3_writerWhat exact S3 action on what exact prefix?
aws_iam_role.receipt_podWhich identity does the Receipt app assume?
Trust policy on the pod roleWhich Kubernetes ServiceAccount may assume this role?
aws_cloudwatch_log_group.receipt_apiWhere do application logs live?

Decision checkpoint

DecisionWhy it matters (from your notes)
random_pet suffix or bucket_prefix?Avoid global bucket name collisions without hardcoding.
Object ARN receipts/* vs bucket ARN?AWS §4: ListBucket needs bucket ARN; PutObject needs object ARN. Do NOT use s3:*.
KMS-managed vs S3-managed encryption?KMS adds a second authorization surface. Start with SSE-S3 for now.
CloudWatch retention set?Set 30 days for learning. "Never expire" costs money.
Pause & Predict
  1. If you change the S3 bucket's bucket_prefix, will Terraform update or replace the bucket? What happens to existing objects?
  2. You delete the aws_iam_role.receipt_pod block and re-add it with a new local name. What does the plan show?
  3. How would you prevent that destroy/create behavior? (TF §State)

Phase 1.4 — VPC, EKS, and Platform IAM

Duration: 4–6 hours — TF Modules, AWS §2, §9, K8s §3

Notes to re-read

What to build

terraform/platform/ — a Terraform root that creates the cluster:

Module/ResourcePurpose
VPC module2 AZs, public + private subnets, VPC endpoints for S3/ECR/STS/CloudWatch. NO NAT gateways (save $30/month).
aws_eks_cluster.receiptControl plane in the VPC.
aws_eks_node_group.app2 × t4g.small or t3.small, spread across AZs, in private subnets.
aws_iam_role.eks_nodeNode role: image pull (ECR), CloudWatch Logs, VPC CNI.
aws_iam_role.alb_controllerAWS Load Balancer Controller IRSA role.
aws_iam_role.external_dnsExternalDNS IRSA role for Route 53.
aws_eks_pod_identity_association.receiptLinks ServiceAccount to IAM role from Phase 1.3.
aws_eks_addon blocksVPC CNI, CoreDNS, kube-proxy, EKS Pod Identity Agent.

Architecture decisions to make and justify

DecisionRead firstWhy the answer matters
Public or private subnets for EKS nodes?AWS §2Nodes need egress to ECR/CloudWatch/S3. VPC endpoints provide it without NAT gateways. Nodes do NOT need public IPs.
VPC CIDR sizing?AWS §2EKS pods consume subnet IPs via VPC CNI. A /24 subnet can support ~100 pods with prefix delegation.
EKS access entries or aws-auth ConfigMap?AWS §9Access entries are the current mechanism. Use them.
Pod Identity vs IRSA?AWS §9Pod Identity is the current default. Simpler association model. Use it.

The Terraform ownership boundary — think before you commit

Terraform should own: VPC, subnets, route tables, endpoints, EKS cluster and node groups, IAM roles and Pod Identity associations, ECR repositories, S3 buckets, CloudWatch log groups, the bootstrap Argo CD installation.

Terraform should NOT own: Helm releases inside the cluster (Argo CD owns those), Application Deployments/Services/Ingresses (Argo CD owns those), Namespaces (Argo CD with CreateNamespace=true).

This boundary is the single most important architectural decision in Project 1. Re-read TF §Modules and AWS §11 before you draw the line.

Pause & Predict
  1. If you delete the VPC module and re-add it with the same values, what does the plan show? Why can't Terraform know the old VPC was the same one?
  2. The EKS cluster takes ~15 minutes to create. If apply fails halfway through, what's your recovery plan?
  3. Your Terraform state contains the EKS cluster's API endpoint. Is that sensitive? How is the state protected?

Phase 1.5 — ECR, CI, and immutable image promotion

Duration: 3–4 hours — AWS §5, §11, Docker §3

Notes to re-read

What to build

terraform/ecr.tf (add to the platform root):

resource "aws_ecr_repository" "receipt_api" {
  name                 = "receipt-api"
  image_tag_mutability = "IMMUTABLE"
  force_delete         = false

  image_scanning_configuration {
    scan_on_push = true
  }
}

terraform/github-oidc.tf — OIDC provider and publisher role with trust policy scoped to your repo and environment. Permissions: ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:CompleteLayerUpload, ecr:UploadLayerPart, ecr:InitiateLayerUpload, ecr:PutImage.

.github/workflows/build.yaml in receipt-api repository:

name: Build receipt image
on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  image:
    runs-on: ubuntu-latest
    environment: production-build
    steps:
      - checkout
      - test (your language's test runner)
      - aws-credentials (OIDC → assume publisher role)
      - ecr-login
      - docker build + push
        tags:
          - git-${{ github.sha }}
        push: true
      - print digest for promotion PR

Do NOT run kubectl apply from CI. CI publishes an artifact and proposes a change. It does NOT touch the cluster.

Decision checkpoint

DecisionWhy it matters
IMMUTABLE tag mutabilityPrevents accidental overwrite of a released tag. Digest is still the truth.
CI environment production-buildThe GitHub environment gate restricts who can trigger the protected workflow.
OIDC subject scopingYour trust policy must pin to the exact repository and environment. Do NOT use wildcard subjects.
Pause & Predict
  1. The CI job pushes with tag git-abc123. Later, you push a rebuild of the same commit. With IMMUTABLE, what happens? Is that good?
  2. The CI job should NOT have Elastic Kubernetes Service permissions. Why?
  3. Your ECR repository has scanOnPush: true. A vulnerability is found in your base image. What does ECR scanning tell you, and what does it NOT tell you?

Phase 1.6 — Helm chart, GitOps repo, and Argo CD

Duration: 4–6 hours — K8s §4–6, AWS §11, TF §Modules

Notes to re-read

What to build

Chart structure in receipt-deploy/:

receipt-deploy/
├── charts/
│   └── receipt-api/
│       ├── Chart.yaml
│       ├── values.yaml              # defaults
│       ├── values.schema.json        # validate values shape
│       └── templates/
│           ├── _helpers.tpl
│           ├── deployment.yaml
│           ├── service.yaml
│           ├── ingress.yaml
│           ├── serviceaccount.yaml
│           └── pdb.yaml
├── environments/
│   └── prod/
│       └── receipt-api.yaml         # prod-specific overrides
└── argocd/
    └── receipt-prod.yaml            # Argo CD Application

values.yaml exposes this contract:

replicaCount: 2

image:
  repository: 123456789012.dkr.ecr.eu-west-2.amazonaws.com/receipt-api
  digest: sha256:...  # ← this is the truth

service:
  port: 80
  targetPort: 8080

ingress:
  host: api.example.com
  certificateArn: "arn:aws:acm:..."

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    memory: 256Mi

podSecurityContext:
  fsGroup: 10001

serviceAccount:
  name: receipt-api

deployment.yaml template key requirements:

  1. Image: {{ .Values.image.repository }}@{{ .Values.image.digest }} — NOT a tag.
  2. readinessProbe on /health/ready.
  3. livenessProbe — lightweight, does NOT call S3 (liveness failure kills containers; an S3 outage should not kill all pods).
  4. securityContext.runAsNonRoot: true, readOnlyRootFilesystem: true, capabilities.drop: [ALL].

ingress.yaml template annotations:

annotations:
  alb.ingress.kubernetes.io/scheme: internet-facing
  alb.ingress.kubernetes.io/target-type: ip
  alb.ingress.kubernetes.io/healthcheck-path: /health/ready
  alb.ingress.kubernetes.io/certificate-arn: "{{ .Values.ingress.certificateArn }}"
  alb.ingress.kubernetes.io/group.name: "{{ .Values.ingress.groupName }}"

argocd/receipt-prod.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: receipt-prod
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/YOUR_USER/receipt-deploy.git
    targetRevision: main
    path: charts/receipt-api
    helm:
      valueFiles:
        - ../../environments/prod/receipt-api.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: receipt
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

From this point on, kubectl apply is forbidden for application resources. Git is the desired state. Argo CD is the reconciler.

Decision checkpoint

DecisionWhy it matters
Why a separate GitOps repo?Review boundary: source code PRs and production configuration PRs have different reviewers, urgency, and blast radius.
Why digest in values, not :latest?You must know exactly which bytes are running. "Latest" moves; a digest is content-addressable truth.
Why automated.prune: true?When a resource is removed from Git, Argo CD deletes it from the cluster.
Why selfHeal: true?A manual kubectl edit is undone by Argo CD. Forces a Git-first discipline.
Why NOT allowEmpty: true?An empty desired state with pruning = mass deletion.
Pause & Predict
  1. You change replicaCount from 2 to 3 in Git. Walk through every component that participates in making that true.
  2. You change the Service selector to app: wrong-label. What happens to the existing pods? What happens to traffic?
  3. Argo CD is OutOfSync after you manually delete a pod. What corrects it?

Phase 1.7 — DNS, TLS, and end-to-end verification

Duration: 2–3 hours — AWS §7, K8s §6

Notes to re-read

What to build

Route 53 hosted zone: A ALIAS record: api.example.com → ALB DNS name

ACM certificate: DNS-validated in the same region as the cluster. ARN passed as ingress.certificateArn in Helm values.

ALB (created by AWS Load Balancer Controller from the Ingress): Internet-facing, TLS terminated at ALB, backend HTTP. Health check path: /health/ready.

Verify end-to-end:

dig api.example.com

curl https://api.example.com/health/ready
# → {"status": "healthy"}

curl -X POST https://api.example.com/receipts \
  -H "Content-Type: application/pdf" \
  --data-binary @test-receipt.pdf
# → {"id": "abc123"}

aws s3 ls s3://receipt-prod-data/receipts/abc123.pdf

aws logs tail /receipt/prod/api --follow

The complete mental movie checklist

StepWhat to verify
DNSdig api.example.com returns the ALB alias
ALBcurl -v shows TLS handshake, correct certificate, target group routing
Pod identitykubectl describe pod shows the correct ServiceAccount; Pod Identity association links it to the IAM role
S3 authorizationReceipt write succeeds without hardcoded credentials
Log deliveryApplication structured logs appear in CloudWatch within seconds
MetricsALB RequestCount and TargetResponseTime visible in CloudWatch

Phase 1.8 — Promotion workflow (CI opens a PR)

Duration: 3–4 hours — AWS §11, TF §Cross-state dependencies

Notes to re-read

What to build

Second GitHub Actions workflow in receipt-api repo:

name: Propose deployment
on:
  workflow_run:
    workflows: [Build receipt image]
    types: [completed]

jobs:
  propose:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - determine the digest from the build run
      - checkout receipt-deploy repo
      - update digest in environments/prod/receipt-api.yaml
      - open a pull request in receipt-deploy repo

The PR body should contain: source commit SHA and link, image digest (the immutable reference), ECR scan results, rendered manifest diff (helm template output difference), rollback digest (the previous value).

Merge strategy: Not automatic. A human reviews the PR and merges it. Merging the PR changes the desired state in Git. Argo CD sees the Git change and reconciles the cluster. The deployment is a Git merge, not a CI action.

Pause & Predict
  1. Why does CI open a PR instead of running kubectl set image? Name 3 architectural reasons.
  2. The PR is merged but Argo CD is down. What happens? When Argo CD restarts, what happens?
  3. The PR changes the digest but also accidentally changes replicaCount from 2 to 20. Where is this caught?

Phase 1.9 — Rollback (by Git revert)

Duration: 1 hour — AWS §11, K8s §5

No code — a practiced procedure:

  1. Identify the bad deploy: which GitOps commit? Which digest?
  2. Revert the commit in receipt-deploy repo (or open a PR restoring the old digest).
  3. Merge the revert. Argo CD renders the old digest.
  4. Deployment controller performs a rolling update back to the old ReplicaSet.
  5. Verify: curl https://api.example.com/health/ready, check CloudWatch error rate.
Pause & Predict
  1. What must be true about the old image for rollback to work? (ECR lifecycle policies can delete old images!)
  2. What must be true about the database/data schema for rollback to work?
  3. If the rollback commit is merged but nothing changes in the cluster, what is the first thing to check?

Phase 1.10 — Break it, one gate at a time

Duration: 2–3 hours — All AWS "Break it" sections

Introduce one failure, predict the symptom, find the evidence, repair it, then move to the next. Keep notes on what you observed.

BreakPredict before you confirm
Change Service selector to a non-matching labelWhich endpoints disappear? Does ALB health fail?
Change readiness probe path to /health/wrongDo pods keep running? Do they receive traffic?
Remove S3 permission from pod roleWhich exact error appears in the app log? Does the pod stay Ready?
Set memory: 64Mi limit, send a large receiptOOMKill? CrashLoopBackOff? Which QoS class?
Delete the Ingress from GitDoes Argo CD delete the ALB? How long does it take?
Stop Argo CD, then change GitWhat drifts? What reconciles when Argo CD resumes?
Manually kubectl scale to 10 replicasHow quickly does selfHeal restore Git state?

Completion criterion for Project 1

"A developer pushes to receipt-api main. A CI build creates an immutable image and opens a PR in receipt-deploy with the new digest. A reviewer merges the PR. Argo CD renders the Helm chart and applies it to EKS. The Deployment controller creates a new ReplicaSet. New pods start, pass readiness, and join the ALB target group. Old pods drain. CloudWatch shows the new digest serving healthy traffic. Rollback is a Git revert of the digest change."

Stop here and verify that sentence before moving to Project 2. If any word in it is fuzzy, re-read the corresponding note section.

Project 2

Platform-as-a-Product

Goal: The Receipt API infrastructure becomes reusable. A new team can deploy a different app on the same platform by writing a single YAML spec, without knowing Terraform or EKS internals. Time: 10–15 hours | Builds on: Project 1 infrastructure

platform.yaml
  → Terraform module call (S3, ECR, IAM, CloudWatch)
  → CI workflow template (build + push)
  → Helm value generation (from the spec)
  → Argo CD Application registration

Phase 2.1 — Extract reusable Terraform modules

Duration: 4–5 hours — TF §Modules (the entire chapter)

Notes to re-read

What to build

modules/app-infra/ — the "golden path" module:

InputTypePurpose
app_namestringUsed in all resource names and tags
s3_write_prefixstringS3 prefix the app may write to
eks_cluster_namestringWhich EKS cluster to register the Pod Identity
namespacestringKubernetes namespace
service_accountstringServiceAccount to associate with IAM role
log_retention_daysnumberCloudWatch log retention

Resources created per app: ECR repository (immutable, scan on push), S3 bucket, IAM role + policy for pod-to-S3 access, Pod Identity association, CloudWatch log group.

Your terraform/platform/ root now looks like:

module "platform" {
  source = "./modules/eks-platform"
  # ... VPC CIDR, AZs, node types ...
}

module "receipt_api" {
  source = "./modules/app-infra"

  app_name           = "receipt-api"
  s3_write_prefix    = "receipts/"
  eks_cluster_name   = module.platform.cluster_name
  namespace          = "receipt"
  service_account    = "receipt-api"
  log_retention_days = 30
}

module "invoice_api" {
  source = "./modules/app-infra"

  app_name           = "invoice-api"
  s3_write_prefix    = "invoices/"
  eks_cluster_name   = module.platform.cluster_name
  namespace          = "invoice"
  service_account    = "invoice-api"
  log_retention_days = 30
}

No new VPC. No new EKS cluster. One module call provisions everything an app needs.

Decision checkpoint

DecisionWhy it matters
One bucket per app or one bucket with prefixes?Cost of many buckets is near-zero. Isolation is stronger with separate buckets. Choose separate for learning.
Expose every field or hide complexity?Your module should hide decisions that should NOT vary: encryption, block public access, scan on push. Expose things that MUST vary.
moved blocks when refactoring?If Receipt API resources moved from root into module, you MUST add moved blocks.
Pause & Predict
  1. You already deployed Receipt API resources from Project 1.4. Now wrapping them in module. Without moved blocks, what does the plan show?
  2. Write the moved blocks you need. How many? For which resources?
  3. After adding invoice_api, Terraform wants to create a second EKS cluster. Where should the cluster live?

Phase 2.2 — Developer spec ("platform.yaml")

Duration: 2–3 hours — TF §Variables, K8s §4–5

What to build

Define a spec file that a developer fills out:

# platform.yaml
name: my-service
team: platform
environment: prod

app:
  port: 8080
  health_path: /health/ready

s3:
  write_prefix: my-data/

resources:
  cpu_request: 100m
  memory_request: 128Mi
  memory_limit: 256Mi

deploy:
  replica_count: 2
  ingress_host: my-service.example.com
  certificate_arn: "arn:aws:acm:..."

git:
  source_repo: example-org/my-service
  deploy_repo: example-org/my-service-deploy

This is NOT a Terraform variable file. It is an abstraction layer above Terraform. A script or CLI tool reads platform.yaml and generates: a Terraform module call, a Helm values file, and an Argo CD Application manifest.

Pause & Predict
  1. Which fields in platform.yaml should NOT vary per environment?
  2. If a developer changes replica_count from 2 to 5, which generated files change? Which system reconciles the change?
  3. The developer picks write_prefix: "data/". Another team uses the same prefix. Is this caught anywhere?

Phase 2.3 — Reusable CI workflow template

Duration: 3–4 hours — AWS §11 (OIDC, protected environments)

What to build

A reusable GitHub Actions workflow that any app team calls:

name: Build and propose deployment

on:
  workflow_call:
    inputs:
      app_name:
        required: true
        type: string
      ecr_repository:
        required: true
        type: string
      aws_region:
        required: true
        type: string
      deploy_repo:
        required: true
        type: string
      values_path:
        required: true
        type: string
    secrets:
      role_to_assume:
        required: true

jobs:
  build-and-propose:
    # ... same logic as Project 1.8, but parameterised

An individual app repo\'s workflow becomes:

name: CI
on:
  push:
    branches: [main]

jobs:
  use-shared:
    uses: example-org/platform/.github/workflows/_build-and-propose.yaml@main
    with:
      app_name: receipt-api
      ecr_repository: receipt-api
      aws_region: eu-west-2
      deploy_repo: example-org/receipt-deploy
      values_path: environments/prod/receipt-api.yaml
    secrets:
      role_to_assume: arn:aws:iam::123456789012:role/receipt-github-ecr-publisher

Decision checkpoint

DecisionWhy it matters
One OIDC role per app or one shared role?Least privilege: each app\'s CI should only push to its own ECR repository. Separate roles.
Does the reusable workflow have IAM admin access?Absolutely not. It pushes to ECR and opens a PR. Nothing else.
How does an app team claim a new ECR repo without knowing Terraform?They write platform.yaml. The generated Terraform creates the repo.

Phase 2.4 — Policy-as-code

Duration: 2–3 hours — TF §Lifecycle rules

What to build

Add validation gates to the Terraform module:

variable "s3_write_prefix" {
  type = string
  validation {
    condition     = can(regex("^[a-zA-Z0-9._/-]+/$", var.s3_write_prefix))
    error_message = "S3 write prefix must end with '/' and contain only safe characters."
  }
}

resource "aws_iam_policy" "receipt_s3" {
  # ...

  lifecycle {
    precondition {
      condition     = endswith(var.s3_write_prefix, "/")
      error_message = "S3 write prefix must end with '/' to prevent accidental broad access."
    }
  }
}

Use a policy scanning tool (Checkov, tfsec, or Open Policy Agent):

checkov -d modules/app-infra
Pause & Predict
  1. A validation rule says memory_limit >= memory_request. Is this checked by Terraform, the EKS scheduler, or the kubelet at runtime?
  2. Checkov flags your bucket as "not encrypted." But you have the encryption config. Why might the scanner miss it?
Project 3

Observability — Know Before the User Does

Goal: When a receipt upload fails, you know before the user opens a ticket. You have logs, metrics, dashboards, and SLO alarms proving it. Time: 10–15 hours | Builds on: Project 1 cluster

Phase 3.1 — Structured application logs

Duration: 1–2 hours — AWS §6 (CloudWatch logs), Docker §6

Notes to re-read

What to build

Upgrade the Receipt API to emit structured JSON logs:

{
  "timestamp": "2026-07-24T10:02:11Z",
  "level": "info",
  "service": "receipt-api",
  "version": "sha256:abc...",
  "request_id": "req-7f1",
  "route": "POST /receipts",
  "duration_ms": 843,
  "status": 201,
  "object_key": "receipts/abc123.pdf",
  "s3_duration_ms": 780
}

Fields NEVER to log: AWS credentials or session tokens, full presigned URLs, receipt content bytes, secrets of any kind.

Use CloudWatch Logs Insights queries:

fields @timestamp, request_id, duration_ms, status
| filter route = "POST /receipts"
| sort @timestamp desc
| limit 20

Phase 3.2 — Prometheus and Grafana stack

Duration: 3–4 hours — K8s §3 (add-ons), K8s §6

What to build

Deploy via Helm or the Prometheus Operator (install via Argo CD as platform add-ons — not via Terraform):

ComponentPurpose
kube-prometheus-stackPrometheus + Alertmanager + Grafana in one deploy
PrometheusScrapes metrics from pods, nodes, and Kubernetes API
GrafanaDashboards — accessed via kubectl port-forward for learning
Loki (optional)Log aggregation — alternative to CloudWatch Insights

Instrument the Receipt API with a /metrics endpoint:

Add a ServiceMonitor:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: receipt-api
  namespace: receipt
spec:
  selector:
    matchLabels:
      app: receipt-api
  endpoints:
    - port: http
      path: /metrics
Pause & Predict
  1. Your /metrics endpoint returns 200 but Prometheus shows "no targets." What are the three most likely causes?
  2. A pod restarts. The Prometheus counter resets to 0. Does this cause a spike in your error rate graph?
  3. You deploy Loki but no logs appear. The app is writing to stdout. List the failure points between stdout and the Loki query interface.

Phase 3.3 — SLO dashboards

Duration: 3–4 hours — AWS §6 (four golden signals, SLO)

Notes to re-read

What to build

Dashboard 1 — Golden Signals (Grafana):

PanelQueryWhy this matters
Request raterate(receipt_requests_total[5m])Traffic
Error raterate(receipt_requests_total{status=~"5.."}[5m]) / rate(receipt_requests_total[5m])Errors
p95 latencyhistogram_quantile(0.95, rate(receipt_request_duration_seconds_bucket[5m]))Latency
Pod CPU/MemoryFrom kube_pod_container_resource_*Saturation
S3 write latencyhistogram_quantile(0.95, rate(receipt_s3_write_duration_seconds_bucket[5m]))Dependency latency

Define an SLO:

SLI:  successful receipt uploads / total valid receipt requests
SLO:  99.5% over a 28-day rolling window
Error budget: 0.5% of requests may fail

Decision checkpoint

DecisionWhy it matters
SLO based on HTTP status OR business outcome?A corrupted PDF that the app stores but is unreadable returns 201. The business signal catches it; the HTTP signal does not.
28-day window?Long enough to smooth noise, short enough to represent recent experience.
p99 vs p95 vs average?Average hides the worst cases. Choose p95 or p99 based on user-facing impact.

Phase 3.4 — Meaningful alarms

Duration: 2–3 hours — AWS §6 (alarms evaluate metrics)

User-impact alarms (page a person):

AlarmConditionSeverity
High error rateerror_rate > 1% for 5minCritical
Zero successful uploadsreceipt_requests_total == 0 for 10minCritical
High p95 latencyp95_latency > 2s for 10minWarning
SLO burn rate criticalerror_budget_burn_rate > 10x for 1hrCritical

Diagnostic alarms (create a ticket):

AlarmConditionSeverity
Pod restarting frequentlykube_pod_container_status_restarts_total > 5 in 30minWarning
Node memory > 90%node_memory_MemAvailable_bytes < 10%Warning
Disk fillingnode_filesystem_avail_bytes < 20%Warning
ImagePullBackOff existskube_pod_status_phase{phase="Pending"} > 0 for 10minWarning
Pause & Predict
  1. Your High error rate alarm fires. You check Grafana: error rate is 0%. What inputs to the alarm might differ from the dashboard query?
  2. You add a new feature that doubles latency. The SLO is still met, but users complain. What does this tell you about SLOs alone?
  3. An alarm fires at 3 AM. The runbook says "check CloudWatch." What should the runbook actually say?

Phase 3.5 — Chaos engineering

Duration: 3–4 hours — All "Break it" sections across your notes

What to build

Install LitmusChaos or Chaos Mesh on the cluster. Create these experiments:

ExperimentHypothesisWhat to observe
Kill one Receipt podALB stops routing to it; remaining pod handles all traffic.ALB target health, error rate, request latency
Kill ALL Receipt podsDeployment controller recreates them. Downtime = time to start + pass readiness.Downtime duration, 503 responses, ALB metrics
Fill the pod\'s memoryOOMKill occurs. Container restarts. No data loss (receipts in S3).OOMKill event, restart count, any failed writes
Remove S3 IAM policy from Pod IdentityAll uploads fail with AccessDenied. Pods remain running and Ready.App logs, CloudTrail, error rate spike
Cordone one node, drain podsPods reschedule. Data in S3 unaffected.Pod eviction events, rescheduling time

For each experiment: write the hypothesis before running it, observe the result, check if your alarms fired, note which alarms fired too late/early/not at all, update alarms accordingly.

Pause & Predict
  1. You kill all pods. The Deployment controller recreates them. Why might the new pods fail readiness checks?
  2. You remove the S3 IAM policy. The app logs AccessDenied. ALB health checks continue to pass. Why?
  3. Is your Zero successful uploads alarm sensitive enough to catch the S3 policy removal?

Phase 3.6 — Runbook

Duration: 2–3 hours — All "Incident" sections in your notes

Write a runbook — a short, actionable document that states what to check in a specific order. It is NOT a wiki. It is what gets you from "alarm fired" to "root cause identified" or "service restored."

Incident 1 — "Users report 503 errors"

  1. What already worked? (DNS? TLS? ALB reachable?)
  2. Check ALB target health: are targets healthy?
  3. Check pod status: kubectl get pods -n receipt
  4. Check recent events: kubectl describe deployment receipt-api -n receipt
  5. Check CloudWatch / Grafana: what changed in the last 15 minutes?

Incident 2 — "Receipt uploads fail with 500"

  1. What changed recently? (Check Argo CD sync status, GitOps commits)
  2. Check app logs: filter for ERROR level
  3. Verify Pod Identity: does the ServiceAccount still have the IAM association?
  4. Check S3 bucket: does the bucket exist? Is the policy intact?
  5. Check VPC endpoints: can the pod reach the S3 endpoint?
  6. Check CloudTrail: who changed what? When?

Incident 3 — "Pods stuck Pending after scale-up"

  1. Describe a pending pod: kubectl describe pod <name> — read the Events section
  2. Is it a node capacity issue? (Insufficient CPU/memory → add nodes)
  3. Is it a subnet IP issue? (VPC CNI exhausted subnet IPs)
  4. Is it a taint/affinity mismatch?
  5. Check node autoscaler logs if configured
Start with the incident diagnosis flowcharts in your notes. The flowcharts are architecture-neutral; your runbook adds the specific names, namespaces, and resource IDs of your cluster.
Project 4

Multi-Environment Container Fleet

Goal: The same Receipt API image runs in three environments — Docker Compose (dev), ECS Fargate (stage), and EKS (prod) — from one CI pipeline. Time: 8–12 hours | Builds on: Project 1 CI + Project 2 modules

Phase 4.1 — Docker Compose development environment

Duration: 2–3 hours — Docker §9 (Compose), §11 (full lifecycle)

Notes to re-read

What to build

compose.yaml in the receipt-api repository root:

name: receipt-dev

services:
  app:
    build:
      context: .
      target: development
    init: true
    environment:
      APP_MODE: development
      S3_ENDPOINT: http://localstack:4566
      S3_BUCKET: receipt-dev-data
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./app.py:/app/app.py:ro     # live code reload
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
      interval: 5s
    depends_on:
      localstack:
        condition: service_healthy

  localstack:
    image: localstack/localstack:latest
    environment:
      SERVICES: s3
    ports:
      - "127.0.0.1:4566:4566"
    volumes:
      - ./localstack-init.sh:/etc/localstack/init/ready.d/init.sh:ro
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
      interval: 5s

Dev loop:

docker compose up -d        # start everything
docker compose logs -f app   # watch logs
# edit app.py — the bind mount syncs it, app auto-reloads
curl http://127.0.0.1:8080/health/ready
docker compose down
Pause & Predict
  1. You set S3_ENDPOINT=http://localhost:4566. The app resolves localhost. Which network namespace does this refer to?
  2. You use 127.0.0.1:8080 for the published port. Why NOT 0.0.0.0:8080?
  3. The build.target is development. If your Dockerfile doesn\'t have named stages, what do you need to add?

Phase 4.2 — ECS Fargate deployment

Duration: 3–4 hours — AWS §8 (ECS)

Notes to re-read

What to build

A new Terraform root terraform/ecs-stage/ that creates:

ResourcePurpose
aws_ecs_cluster.receiptFargate cluster (no EC2 instances)
aws_ecs_task_definition.receiptSame image digest as production, Fargate CPU/memory
aws_ecs_service.receiptDesired count 1, Fargate launch type
aws_iam_role.ecs_executionPulls from ECR, writes to CloudWatch
aws_iam_role.ecs_taskSame S3 permissions as EKS pod role
aws_lb_target_group.receiptALB target group with health check
aws_lb_listener_rule.receiptRoute stage-api.example.com → ECS target group

Key learning differences from EKS

AspectEKS (Project 1)ECS (this phase)
Worker nodesYou manage EC2 (or Fargate profiles)Fargate abstracts all nodes
Deployment modelHelm + Argo CD GitOpsECS service + task definition revision
Image identityNode role pulls image; Pod Identity for runtimeTask execution role pulls image; task role for runtime
NetworkingPod gets VPC CNI IPTask gets ENI + awsvpc IP
Health checksKubernetes liveness/readiness probesALB target-group health check + optional ECS container health
Rolling updateDeployment controller via ReplicaSetsECS service deployment circuit breaker

Decision checkpoint

DecisionWhy it matters
Fargate vs EC2 launch type?Fargate removes node management but costs more per vCPU-hour. For learning comparison, Fargate is ideal.
Same image digest as prod?YES. That\'s the point — build once, run anywhere.
Why a separate Terraform root?Different state, different lifecycle, different blast radius.
Pause & Predict
  1. Your ECS task keeps stopping with CannotPullContainerError. Which IAM role is wrong? Execution or task?
  2. The task runs but curl returns 503. The container is healthy. What is the first thing to check?
  3. ECS deployment circuit breaker vs Argo CD sync. Both handle failed rollouts. How do their approaches differ?

Phase 4.3 — Multi-arch image builds

Duration: 2–3 hours — Docker §5 (multi-platform), AWS §5

What to build

Upgrade the CI build workflow to produce a multi-arch image index:

- name: Build and push multi-platform
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    platforms: linux/amd64,linux/arm64
    tags: |
      ${{ steps.ecr.outputs.registry }}/receipt-api:git-${{ github.sha }}

ECR stores a manifest list pointing to platform-specific manifests. When a node pulls, an x86 node gets linux/amd64; an ARM node (t4g) gets linux/arm64.

docker buildx imagetools inspect \
  123456789012.dkr.ecr.eu-west-2.amazonaws.com/receipt-api:git-abc123
Pause & Predict
  1. Your CI builds linux/arm64 on a GitHub Actions ubuntu-latest runner (which is amd64). How does it work?
  2. You run a t4g (ARM) EKS node group. Your image tag was pushed as amd64 only. What error appears?

Phase 4.4 — Tiered CI pipeline

Duration: 2–3 hours — AWS §11 (CI vs CD, pull requests are untrusted input)

What to build

A single CI pipeline with environment gates:

jobs:
  test:
    # Runs on every push to any branch
    # No AWS credentials needed

  build-dev:
    needs: test
    if: github.ref == 'refs/heads/main'
    # Build with tag git-<sha>-dev
    # Uses development ECR repository

  build-prod:
    needs: build-dev
    environment: production-build
    # Build with tag git-<sha>
    # Uses production ECR repository
    # Opens the promotion PR in receipt-deploy

Key principles

  1. PR code is untrusted input (AWS §11). Never give a PR branch workflow production AWS credentials.
  2. Build once, promote the same digest (Docker §1). The dev and prod builds should produce identical digests. Only the tag differs.
  3. Environment gates (AWS §11). The production-build environment requires approved reviewers before the job runs.
Pause & Predict
  1. A developer opens a PR from a fork. The PR workflow includes configure-aws-credentials. Can the fork access your AWS account?
  2. What ensures the production image is the same bytes as the tested dev image?
  3. What is the difference between environment: production-build and a branch protection rule? Which one controls AWS credential access?
Project 5

Kubernetes Incident Auto-Remediation Operator

Goal: An operator that watches pod failures, captures diagnostic evidence, and takes bounded recovery actions — without human intervention. Time: 12–18 hours | Learning curve: This is the hardest project. It demands K8s internals, Go programming, and the reconciliation-loop mindset from K8s §1.

Phase 5.1 — Design the operator\'s contract

Duration: 2–3 hours — K8s §1 (reconciliation loops), K8s §3 (control loops)

Notes to re-read

What to design

An operator is a custom Kubernetes controller. It watches resources, observes their state, and acts to close the gap between actual and desired.

The CRD you\'ll create — IncidentResponder:

apiVersion: remediation.example.com/v1
kind: IncidentResponder
metadata:
  name: crash-loop-responder
  namespace: receipt
spec:
  watch:
    namespace: receipt
    podLabelSelector:
      app: receipt-api
  triggers:
    - type: CrashLoopBackOff
      maxRestarts: 3
      action: captureAndRestart
    - type: OOMKilled
      action: captureOnly
    - type: ImagePullBackOff
      action: notifyOnly
  evidence:
    capturePodLogs: true
    captureNodeConditions: true
    captureRecentEvents: true
  actions:
    restartThreshold: 3
    cordonNodeOnRepeatedFailure: true
    maxCordonNodes: 1
  notifications:
    slackWebhookUrlRef:
      name: slack-webhook
      key: url

Spec explanation:

FieldMeaning
watchWhich pods to monitor (namespace + label selector)
triggersWhat failure conditions to detect and what action to take
evidenceWhat to capture when a failure is detected
actionsRecovery actions and their thresholds
notificationsWhere to send incident summaries

Status (written by the operator, read by you):

status:
  observedGeneration: 1
  conditions:
    - type: Ready
      status: "True"
      lastTransitionTime: "2026-07-24T10:00:00Z"
  lastIncidents:
    - timestamp: "2026-07-24T09:55:00Z"
      pod: receipt-api-7d8f9-abc12
      trigger: OOMKilled
      action: captureOnly
      summary: "Pod OOMKilled. Logs captured. Memory limit was 128Mi; pod was using 300Mi+."
Pause & Predict
  1. Why is the operator using spec and status fields? (K8s §1 — Principle #4)
  2. This CRD describes a single operator instance. What if you wanted to respond to failures across ALL namespaces?
  3. The operator needs to list pods, read pod logs, and update node conditions. What RBAC permissions does its ServiceAccount need?

Phase 5.2 — Scaffold the Go project with controller-gen

Duration: 2–3 hours — Kubernetes controller patterns

What to build

Use kubebuilder or operator-sdk to scaffold:

mkdir incident-operator && cd incident-operator
kubebuilder init --domain example.com --repo github.com/YOUR_USER/incident-operator
kubebuilder create api \\
  --group remediation --version v1 --kind IncidentResponder \\
  --resource --controller

This generates:

incident-operator/
├── api/v1/
│   ├── incidentresponder_types.go   # ← you fill in Spec/Status structs
│   ├── groupversion_info.go
│   └── zz_generated.deepcopy.go
├── internal/controller/
│   └── incidentresponder_controller.go  # ← you fill in Reconcile
├── config/
│   ├── crd/          # CRD YAML
│   ├── rbac/         # RBAC for the operator
│   └── manager/      # Deployment for the operator itself
├── main.go
├── go.mod
├── Makefile
└── Dockerfile

Let the LLM agent generate the boilerplate. Your job is filling in the Reconcile function and the notification/action logic.

Phase 5.3 — Implement the reconciliation loop

Duration: 4–6 hours — K8s §1 (reconciliation loop pattern)

Notes to re-read

What the Reconcile function does

Reconcile(ctx, req) → (ctrl.Result, error):

1. Fetch the IncidentResponder CR for this request
2. List pods matching the watch selector
3. For each pod:
   a. Check container statuses for CrashLoopBackOff, OOMKilled, ImagePullBackOff
   b. If a trigger matches and the threshold is exceeded:
      i.   Capture evidence: pod logs, node conditions, recent events
      ii.  Format an incident summary
      iii. Take the configured action:
           - captureOnly: log the summary, update CR status
           - captureAndRestart: capture, then delete the pod (Deployment recreates it)
           - notifyOnly: capture, post to Slack/PagerDuty
      iv.  If cordonNodeOnRepeatedFailure and same-node failures exceed threshold:
           Cordon the node
4. Update CR status with incident history
5. Requeue after a configurable interval

Go code structure

func (r *IncidentResponderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var cr remediationv1.IncidentResponder
    if err := r.Get(ctx, req.NamespacedName, &cr); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    var pods corev1.PodList
    selector, _ := metav1.LabelSelectorAsSelector(&cr.Spec.Watch.PodLabelSelector)
    r.List(ctx, &pods, client.InNamespace(cr.Spec.Watch.Namespace),
        client.MatchingLabelsSelector{Selector: selector})

    for _, pod := range pods.Items {
        for _, containerStatus := range pod.Status.ContainerStatuses {
            if match := evaluateTriggers(cr.Spec.Triggers, containerStatus, pod); match != nil {
                evidence := captureEvidence(ctx, r, pod, containerStatus)
                takeAction(ctx, r, match.Action, pod, evidence)
                sendNotification(cr.Spec.Notifications, evidence)
            }
        }
    }

    r.Status().Update(ctx, &cr)
    return ctrl.Result{RequeueAfter: cr.Spec.RequeueInterval.Duration}, nil
}

What you must understand (not the LLM)

  1. Level-based, not edge-triggered: If the operator crashes after capturing evidence but before updating status, the next reconciliation pass sees the same pod state and re-evaluates. (K8s §1)
  2. Idempotent actions: Calling deletePod twice should be safe. The second delete on an already-gone pod should not fail the reconciliation.
  3. Status is the output: The CR\'s status.lastIncidents is how you know what the operator did. Never hide incidents in local logs.

Phase 5.4 — Evidence capture

Duration: 2–3 hours — K8s §4 (pod lifecycle), K8s §10 (request lifecycle)

What to build

type IncidentEvidence struct {
    Timestamp           metav1.Time
    PodName             string
    PodNamespace        string
    NodeName            string
    Trigger             string
    ContainerName       string
    RestartCount        int32
    ImageDigest         string

    PodLogsTail         string   // last N lines
    NodeConditions      []string // relevant node conditions
    RecentEvents        []string // events for this pod
    PreviousContainerLog string  // logs from --previous if CrashLoopBackOff
}

Capture functions: Pod logs (tail, last 50 lines), Node conditions (True status ones), Recent events for this pod, Previous container logs (if CrashLoopBackOff).

Key detail: Use pod.Status.ContainerStatuses[0].ImageID for the digest. pod.Spec.Containers[0].Image returns what was in the pod spec — which might be a tag. Always capture ImageID for incident evidence.

Pause & Predict
  1. The pod is in CrashLoopBackOff. RestartCount is 5. The current container logs are empty. Where do you get the logs from the previous crash?
  2. The node has condition MemoryPressure: True. But the pod was OOMKilled because of its own memory limit. Which condition matters? Both.

Phase 5.5 — Actions and notifications

Duration: 3–4 hours — K8s §9 (cordon, scheduling)

What to build

Action implementations:

type ActionType string

const (
    ActionCaptureOnly      ActionType = "captureOnly"
    ActionCaptureAndRestart ActionType = "captureAndRestart"
    ActionNotifyOnly      ActionType = "notifyOnly"
)

func takeAction(ctx context.Context, r client.Client, action ActionType,
                 pod corev1.Pod, evidence IncidentEvidence) error {
    switch action {
    case ActionCaptureOnly:
        return nil  // Evidence appears in CR status

    case ActionCaptureAndRestart:
        return r.Delete(ctx, &pod)
        // The owning controller (Deployment/ReplicaSet) recreates it

    case ActionNotifyOnly:
        return nil  // Evidence sent via notification channel
    }
    return nil
}

Cordon node logic:

func maybeCordonNode(ctx context.Context, r client.Client,
                      cr Remediationv1.IncidentResponder, nodeName string) error {
    recentOnNode := countRecentIncidentsOnNode(cr, nodeName)
    if recentOnNode < cr.Spec.Actions.RestartThreshold {
        return nil
    }

    var node corev1.Node
    r.Get(ctx, client.ObjectKey{Name: nodeName}, &node)
    node.Spec.Unschedulable = true
    return r.Update(ctx, &node)
}

Cordoning does NOT evict existing pods. It prevents new pods from being scheduled to this node. Never automate pod eviction or node termination without a human approving — that\'s a dangerous automation boundary.

Notification: Post to Slack via webhook with incident summary: namespace, node, trigger, restarts, image digest, action taken, log tail.

Pause & Predict
  1. You delete a pod (captureAndRestart). The new pod has the SAME image. What makes you think it won\'t crash again?
  2. You cordon a node. Existing pods keep running. New pods go elsewhere. What happens if the cluster has no spare capacity?
  3. Your operator has permission to cordon nodes. What\'s the blast radius if the operator has a bug and cordons the control plane node?

Phase 5.6 — Helm chart for the operator

Duration: 2–3 hours — K8s §4–5 (Deployments, ServiceAccounts, RBAC)

What to build

A Helm chart charts/incident-operator/ with:

incident-operator/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── serviceaccount.yaml
    ├── clusterrole.yaml
    ├── clusterrolebinding.yaml
    ├── deployment.yaml
    └── crd/
        └── incidentresponders.yaml

RBAC template — least privilege:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: incident-operator
rules:
  - apiGroups: [remediation.example.com]
    resources: [incidentresponders]
    verbs: [get, list, watch]
  - apiGroups: [remediation.example.com]
    resources: [incidentresponders/status]
    verbs: [update]

  - apiGroups: [""]
    resources: [pods, pods/log]
    verbs: [get, list, watch]
  - apiGroups: [""]
    resources: [pods]
    verbs: [delete]

  - apiGroups: [""]
    resources: [nodes]
    verbs: [get, list, watch, update]

  - apiGroups: [""]
    resources: [events]
    verbs: [get, list, watch]

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: incident-operator
  namespace: incident-operator-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: incident-operator
  template:
    spec:
      serviceAccountName: incident-operator
      containers:
        - name: operator
          image: "{{ .Values.image.repository }}@{{ .Values.image.digest }}"
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi
Pause & Predict
  1. The operator has delete on pods in all namespaces. Is that too broad? How would you scope it to only the receipt namespace?
  2. The operator\'s Deployment runs with replicas: 1. Two replicas would both watch pods and might both try to delete the same pod. Should you use leader election?

Phase 5.7 — Deploy, trigger, observe

Duration: 2–3 hours — K8s §10 (the full lifecycle trace)

What to build — a test plan

1. Deploy the operator via Argo CD alongside Receipt API.

2. Create an IncidentResponder CR:

apiVersion: remediation.example.com/v1
kind: IncidentResponder
metadata:
  name: receipt-responder
  namespace: receipt
spec:
  watch:
    namespace: receipt
    podLabelSelector:
      app: receipt-api
  triggers:
    - type: OOMKilled
      maxRestarts: 3
      action: captureOnly
    - type: CrashLoopBackOff
      maxRestarts: 5
      action: captureAndRestart
  evidence:
    capturePodLogs: true
    captureNodeConditions: true
    captureRecentEvents: true
  actions:
    restartThreshold: 5
    cordonNodeOnRepeatedFailure: true
    maxCordonNodes: 1
  notifications:
    slackWebhookUrlRef:
      name: slack-webhook
      key: url
  requeueInterval: 30s

3. Trigger each scenario:

TestHow to triggerExpected response
OOMKillSet memory limit to 32Mi, send a large PDFCR status updated with incident evidence. Pod restarts (kubelet). Operator captures logs.
CrashLoopBackOffDeploy image with deliberate crash on startupAfter 5 restarts, operator deletes pod. Deployment recreates it. If keeps crashing, cordon triggered.
ImagePullBackOffDeploy with nonexistent image tagNotify via Slack (no deletion).
Repeated failures on same nodeCrashLoopBackOff repeatedly, scheduled to same nodeAfter restartThreshold, node cordoned.

4. Verify:

kubectl get incidentresponder receipt-responder -n receipt -o yaml

Completion criterion for Project 5

"A pod crashes with OOMKilled. The kubelet restarts it. After three restarts, the IncidentResponder operator\'s Reconcile loop notices the container status matches the OOMKilled trigger. It captures pod logs, node conditions, and recent events. It writes an incident summary to the CR status. It does NOT delete the pod (action is captureOnly). The incident appears on the Slack channel. An on-call engineer sees it, checks the Grafana dashboard, and increases the pod\'s memory limit in the Helm values. Argo CD applies the change. The next deployment uses the correct resource limits."

Cross-cutting principles — apply to all 5 projects

The "own your decisions" checklist

Before calling any project "done," answer these in writing:

  1. Which tool owns each resource\'s desired state? (Terraform, Argo CD, the operator, a human with kubectl?)
  2. Which identity performs each action? (Human IAM role? CI OIDC role? Pod Identity? Node role? Execution role?)
  3. What evidence proves it worked? (CloudWatch log? Argo CD sync status? Grafana dashboard? CR status?)
  4. How do you roll back? (Git revert? Terraform destroy/recreate? Helm rollback?)
  5. What breaks if you lose this resource? (State loss? Data loss? Availability impact?)

The "find the boundary" exercise

For any failure, ask the 8-question checklist from your AWS notes (AWS §12, "One-Page Mental Model"):

  1. Name: did it resolve?
  2. Route: can packets reach and return?
  3. Filter: do network controls permit the flow?
  4. Identity: which credentials are actually used?
  5. Authorization: is the exact action/resource allowed?
  6. Health: is the target ready, not merely alive?
  7. Ownership: which controller should make this state true?
  8. Evidence: what proves every answer?
If you can answer all eight for any failure in your system, you have genuinely internalised the notes. The person who can do this in an interview — not by reciting, but by reasoning aloud — gets the offer.

How to talk about these projects in interviews

When asked "Tell me about a project you built":

  1. Start with the problem the project solved (not the tools).
  2. Describe one decision you made and why (e.g., "We chose Pod Identity over static access keys because...").
  3. Describe one failure and how you diagnosed it ("The ALB returned 503, so I checked target health first, not Route 53, because DNS had already resolved...").
  4. End with the evidence that it worked correctly.

The tools (EKS, Terraform, Argo CD) are vocabulary. The reasoning is the skill. Your notes give you the reasoning. These projects give you the evidence.

Maintenance — how to keep these projects interview-ready

  1. Build nightly: set a GitHub Actions schedule to rebuild the Receipt API image daily. If dependencies have changed, you catch it early.
  2. Terraform plan weekly: run terraform plan against your platform. If nothing drifts, you have evidence of stability. If something drifted, you have a debugging exercise.
  3. Rotate an IAM credential quarterly: revoke the pod role, redeploy, observe the failure, restore it, and verify recovery. This proves you know how to handle AccessDenied.
  4. Run one chaos experiment monthly: pick a random failure from Phase 3.5. Time how long it takes to detect it (alarm), diagnose it (dashboard), and restore it (GitOps revert or Terraform apply).
  5. Document your incidents in the runbook: every real failure you encounter gets a new entry. Before the interview, your runbook should have 5+ real incidents with timelines and resolutions.