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.
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
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 file | Path | Nickname in this guide |
|---|---|---|
| AWS & DevOps A to Z | aws-devops-merged.md | AWS \xa7N |
| Docker \u2014 First Principles | docker-merged.md | Docker \xa7N |
| Terraform \u2014 Understand the Machine | terraform-merged.md | TF \xa7N |
| Learn Kubernetes \u2014 First Principles | learn kubernetis/merged.md | K8s \xa7N |
What LLM agents should do
- Scaffold boilerplate: Dockerfiles, Terraform resource stubs, Helm chart skeletons, GitHub Actions workflow YAML, Go project structure.
- Fix syntax: correct HCL indentation, YAML schema errors, Go compilation.
- Generate repetitive blocks: \u201cwrite 10 similar IAM policies for these roles.\u201d
- Explain errors: paste the terminal output, ask it to trace the failure.
What you must do
- Read the referenced note sections before each phase.
- Answer every Pause & Predict question aloud before proceeding.
- Choose architecture boundaries: which resources share state, which modules are reusable, where to draw the GitOps/terraform ownership line.
- Review every plan before apply. The agent writes HCL; you approve
+ create / ~ update / -/+ destroy then create. - 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:
- Use
t3.microort4g.smallEKS nodes (2 nodes max during learning). - Choose Fargate only for the ECS phase of Project 4, then tear it down.
- Destroy the EKS cluster between sessions. Terraform recreates it reliably.
- Use
gp3volumes (included free tier up to 30 GB). - Avoid NAT gateways unless explicitly testing multi-AZ egress; use VPC endpoints for S3/ECR/STS/CloudWatch instead.
- Set CloudWatch log retention to 7 days.
Estimated worst-case cost: $40\u201380/month with EKS running 24/7. With disciplined tear-down: $15\u201325/month.
Time budget
| Project | Estimated hours | Can parallelize with |
|---|---|---|
| P1 \xb7 GitOps Platform | 15\u201320 | Nothing; it is the foundation |
| P2 \xb7 Platform-as-Product | 10\u201315 | Build on P1 infrastructure |
| P3 \xb7 Observability | 10\u201315 | Deploy on P1 cluster alongside Receipt API |
| P4 \xb7 Multi-Environment | 8\u201312 | Reuses P1 CI patterns, adds ECS |
| P5 \xb7 K8s Operator | 12\u201318 | Runs 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:
- Reads the request body as bytes.
- Stores it in S3 using the AWS SDK.
- Logs a structured JSON line with request-id, object-key, duration-ms, status.
- 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.
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
Notes to re-read
- AWS §0 (entire chapter: “What AWS actually is”)
- AWS §2 (VPC: “Where may packets travel?”)
- TF §Expedition One (“The three-world map”)
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"]
- Which AWS component does the application authentication (human user)?
- Which AWS component does the infrastructure authentication (pod → S3)?
- Why are they different?
Phase 1.2 — Receipt API (containerised)
Notes to re-read
- Docker §1 (First Principles — image vs container, build time vs runtime)
- Docker §4 (Images and Layers — tags vs digests)
- Docker §5 (Dockerfiles and BuildKit — multi-stage, non-root, cache order)
- Docker §10 (Security — run as non-root, read-only rootfs, drop capabilities)
What to build
receipt-api/Dockerfile — a production-minded image:
- Multi-stage if your language benefits from it (Go, Rust: yes; Python, Node: less so).
- Final stage runs as non-root user (UID 10001).
HEALTHCHECKprobes/health/ready.- Exec-form
CMD. - No secrets baked into layers.
- Uses a trusted base image (not
:latestfloating reference).
receipt-api/app.py (or equivalent) — the API itself:
POST /receipts→ store in S3.GET /health/ready→ return 200.- Structured JSON logging to stdout.
- SIGTERM handler for graceful shutdown (Docker §6).
- Validates that required env vars exist at startup.
Decision checkpoint
| Decision | Why 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). |
- Your Dockerfile copies
requirements.txtbeforeapp.py. Why does the order matter for build cache? (Docker §5) - 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)
Notes to re-read
- TF §Opening Puzzle (the rename that deletes a server)
- TF §Expedition One (init → fmt → validate → plan → apply → destroy cycle)
- TF §The language workshop (variables, locals, outputs, resource blocks)
- TF §State (the four sentences on what state actually is)
- AWS §1 (IAM: roles, trust policies, permissions policies, Pod Identity)
- AWS §4 (S3: object semantics, versioning, block public access, encryption)
What to build
A standalone Terraform root terraform/foundation/ that creates:
| Resource | This module answers... |
|---|---|
aws_s3_bucket.receipts | Where do receipt PDFs survive pod replacement? |
aws_s3_bucket_versioning | Can we recover from accidental overwrite? |
aws_s3_bucket_public_access_block | Is the bucket definitely private? |
aws_s3_bucket_server_side_encryption_configuration | Are objects encrypted at rest by default? |
aws_iam_policy.receipt_s3_writer | What exact S3 action on what exact prefix? |
aws_iam_role.receipt_pod | Which identity does the Receipt app assume? |
| Trust policy on the pod role | Which Kubernetes ServiceAccount may assume this role? |
aws_cloudwatch_log_group.receipt_api | Where do application logs live? |
Decision checkpoint
| Decision | Why 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. |
- If you change the S3 bucket's
bucket_prefix, will Terraform update or replace the bucket? What happens to existing objects? - You delete the
aws_iam_role.receipt_podblock and re-add it with a new local name. What does the plan show? - How would you prevent that destroy/create behavior? (TF §State)
Phase 1.4 — VPC, EKS, and Platform IAM
Notes to re-read
- AWS §2 (VPC — the whole chapter, especially the packet checklist)
- AWS §9 (EKS — control plane vs data plane, the three identity doors, VPC CNI)
- TF §Modules (boundaries of meaning, flat composition, moved blocks)
- K8s §3 (Cluster Architecture — control plane components, node components)
- K8s §1 (First Principles — reconciliation loops, spec vs status)
What to build
terraform/platform/ — a Terraform root that creates the cluster:
| Module/Resource | Purpose |
|---|---|
| VPC module | 2 AZs, public + private subnets, VPC endpoints for S3/ECR/STS/CloudWatch. NO NAT gateways (save $30/month). |
aws_eks_cluster.receipt | Control plane in the VPC. |
aws_eks_node_group.app | 2 × t4g.small or t3.small, spread across AZs, in private subnets. |
aws_iam_role.eks_node | Node role: image pull (ECR), CloudWatch Logs, VPC CNI. |
aws_iam_role.alb_controller | AWS Load Balancer Controller IRSA role. |
aws_iam_role.external_dns | ExternalDNS IRSA role for Route 53. |
aws_eks_pod_identity_association.receipt | Links ServiceAccount to IAM role from Phase 1.3. |
aws_eks_addon blocks | VPC CNI, CoreDNS, kube-proxy, EKS Pod Identity Agent. |
Architecture decisions to make and justify
| Decision | Read first | Why the answer matters |
|---|---|---|
| Public or private subnets for EKS nodes? | AWS §2 | Nodes need egress to ECR/CloudWatch/S3. VPC endpoints provide it without NAT gateways. Nodes do NOT need public IPs. |
| VPC CIDR sizing? | AWS §2 | EKS 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 §9 | Access entries are the current mechanism. Use them. |
| Pod Identity vs IRSA? | AWS §9 | Pod 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.
- 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?
- The EKS cluster takes ~15 minutes to create. If apply fails halfway through, what's your recovery plan?
- 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
Notes to re-read
- AWS §5 (ECR: registry, repository, image, tag vs digest, tag immutability)
- AWS §11 (CI/CD: OIDC, protected environments, build once, promote the digest)
- Docker §3 (Architecture — BuildKit, registry, distribution)
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
| Decision | Why it matters |
|---|---|
IMMUTABLE tag mutability | Prevents accidental overwrite of a released tag. Digest is still the truth. |
CI environment production-build | The GitHub environment gate restricts who can trigger the protected workflow. |
| OIDC subject scoping | Your trust policy must pin to the exact repository and environment. Do NOT use wildcard subjects. |
- The CI job pushes with tag
git-abc123. Later, you push a rebuild of the same commit. WithIMMUTABLE, what happens? Is that good? - The CI job should NOT have Elastic Kubernetes Service permissions. Why?
- 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
Notes to re-read
- K8s §4 (Pods — lifecycle, probes, pod as atom)
- K8s §5 (Deployments — ReplicaSet relationship, rolling update parameters)
- K8s §6 (Networking — the four problems, Service types, Ingress)
- AWS §11 (Helm, GitOps repository, Argo CD, promotion is a Git diff)
- K8s §1 (reconciliation — Argo CD is a continuous reconciler of Git → cluster)
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:
- Image:
{{ .Values.image.repository }}@{{ .Values.image.digest }}— NOT a tag. readinessProbeon/health/ready.livenessProbe— lightweight, does NOT call S3 (liveness failure kills containers; an S3 outage should not kill all pods).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
| Decision | Why 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. |
- You change
replicaCountfrom 2 to 3 in Git. Walk through every component that participates in making that true. - You change the Service
selectortoapp: wrong-label. What happens to the existing pods? What happens to traffic? - Argo CD is
OutOfSyncafter you manually delete a pod. What corrects it?
Phase 1.7 — DNS, TLS, and end-to-end verification
Notes to re-read
- AWS §7 (Route 53 + ALB — the whole chapter, especially "Trace it")
- K8s §6 (Internet ↔ Service, the Ingress mental model)
- AWS §6 (CloudWatch — logs, metrics, evidence chain)
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
| Step | What to verify |
|---|---|
| DNS | dig api.example.com returns the ALB alias |
| ALB | curl -v shows TLS handshake, correct certificate, target group routing |
| Pod identity | kubectl describe pod shows the correct ServiceAccount; Pod Identity association links it to the IAM role |
| S3 authorization | Receipt write succeeds without hardcoded credentials |
| Log delivery | Application structured logs appear in CloudWatch within seconds |
| Metrics | ALB RequestCount and TargetResponseTime visible in CloudWatch |
Phase 1.8 — Promotion workflow (CI opens a PR)
Notes to re-read
- AWS §11 (Promotion is a Git diff, the complete deployment mental movie)
- K8s §1 (spec vs status — Argo CD closes this gap)
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.
- Why does CI open a PR instead of running
kubectl set image? Name 3 architectural reasons. - The PR is merged but Argo CD is down. What happens? When Argo CD restarts, what happens?
- The PR changes the digest but also accidentally changes
replicaCountfrom 2 to 20. Where is this caught?
Phase 1.9 — Rollback (by Git revert)
No code — a practiced procedure:
- Identify the bad deploy: which GitOps commit? Which digest?
- Revert the commit in
receipt-deployrepo (or open a PR restoring the old digest). - Merge the revert. Argo CD renders the old digest.
- Deployment controller performs a rolling update back to the old ReplicaSet.
- Verify:
curl https://api.example.com/health/ready, check CloudWatch error rate.
- What must be true about the old image for rollback to work? (ECR lifecycle policies can delete old images!)
- What must be true about the database/data schema for rollback to work?
- 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
Introduce one failure, predict the symptom, find the evidence, repair it, then move to the next. Keep notes on what you observed.
| Break | Predict before you confirm |
|---|---|
| Change Service selector to a non-matching label | Which endpoints disappear? Does ALB health fail? |
Change readiness probe path to /health/wrong | Do pods keep running? Do they receive traffic? |
| Remove S3 permission from pod role | Which exact error appears in the app log? Does the pod stay Ready? |
Set memory: 64Mi limit, send a large receipt | OOMKill? CrashLoopBackOff? Which QoS class? |
| Delete the Ingress from Git | Does Argo CD delete the ALB? How long does it take? |
| Stop Argo CD, then change Git | What drifts? What reconciles when Argo CD resumes? |
Manually kubectl scale to 10 replicas | How quickly does selfHeal restore Git state? |
Completion criterion for Project 1
"A developer pushes toreceipt-apimain. A CI build creates an immutable image and opens a PR inreceipt-deploywith 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.
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
Notes to re-read
- TF §Modules (good modules raise the level of conversation, module contract, flat composition, moved blocks)
- AWS §1 (least privilege — each app gets its own IAM role)
- AWS §4 (bucket structure — each app gets its own S3 prefix)
What to build
modules/app-infra/ — the "golden path" module:
| Input | Type | Purpose |
|---|---|---|
app_name | string | Used in all resource names and tags |
s3_write_prefix | string | S3 prefix the app may write to |
eks_cluster_name | string | Which EKS cluster to register the Pod Identity |
namespace | string | Kubernetes namespace |
service_account | string | ServiceAccount to associate with IAM role |
log_retention_days | number | CloudWatch 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
| Decision | Why 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. |
- You already deployed Receipt API resources from Project 1.4. Now wrapping them in module. Without moved blocks, what does the plan show?
- Write the moved blocks you need. How many? For which resources?
- After adding invoice_api, Terraform wants to create a second EKS cluster. Where should the cluster live?
Phase 2.2 — Developer spec ("platform.yaml")
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.
- Which fields in platform.yaml should NOT vary per environment?
- If a developer changes replica_count from 2 to 5, which generated files change? Which system reconciles the change?
- The developer picks write_prefix: "data/". Another team uses the same prefix. Is this caught anywhere?
Phase 2.3 — Reusable CI workflow template
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
| Decision | Why 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
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
- A validation rule says memory_limit >= memory_request. Is this checked by Terraform, the EKS scheduler, or the kubelet at runtime?
- Checkov flags your bucket as "not encrypted." But you have the encryption config. Why might the scanner miss it?
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
Notes to re-read
- AWS §6 (entire chapter, especially "Logs should be structured evidence")
- Docker §6 (Logs: stdout and stderr are the contract)
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
What to build
Deploy via Helm or the Prometheus Operator (install via Argo CD as platform add-ons — not via Terraform):
| Component | Purpose |
|---|---|
| kube-prometheus-stack | Prometheus + Alertmanager + Grafana in one deploy |
| Prometheus | Scrapes metrics from pods, nodes, and Kubernetes API |
| Grafana | Dashboards — accessed via kubectl port-forward for learning |
| Loki (optional) | Log aggregation — alternative to CloudWatch Insights |
Instrument the Receipt API with a /metrics endpoint:
receipt_requests_total{status="201"}(counter)receipt_request_duration_seconds(histogram)receipt_s3_write_duration_seconds(histogram)receipt_requests_in_flight(gauge)
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
- Your /metrics endpoint returns 200 but Prometheus shows "no targets." What are the three most likely causes?
- A pod restarts. The Prometheus counter resets to 0. Does this cause a spike in your error rate graph?
- 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
Notes to re-read
- AWS §6 (golden signals, SLO, error budget)
- K8s §5 (readiness probe gates traffic)
What to build
Dashboard 1 — Golden Signals (Grafana):
| Panel | Query | Why this matters |
|---|---|---|
| Request rate | rate(receipt_requests_total[5m]) | Traffic |
| Error rate | rate(receipt_requests_total{status=~"5.."}[5m]) / rate(receipt_requests_total[5m]) | Errors |
| p95 latency | histogram_quantile(0.95, rate(receipt_request_duration_seconds_bucket[5m])) | Latency |
| Pod CPU/Memory | From kube_pod_container_resource_* | Saturation |
| S3 write latency | histogram_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
| Decision | Why 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
User-impact alarms (page a person):
| Alarm | Condition | Severity |
|---|---|---|
| High error rate | error_rate > 1% for 5min | Critical |
| Zero successful uploads | receipt_requests_total == 0 for 10min | Critical |
| High p95 latency | p95_latency > 2s for 10min | Warning |
| SLO burn rate critical | error_budget_burn_rate > 10x for 1hr | Critical |
Diagnostic alarms (create a ticket):
| Alarm | Condition | Severity |
|---|---|---|
| Pod restarting frequently | kube_pod_container_status_restarts_total > 5 in 30min | Warning |
| Node memory > 90% | node_memory_MemAvailable_bytes < 10% | Warning |
| Disk filling | node_filesystem_avail_bytes < 20% | Warning |
| ImagePullBackOff exists | kube_pod_status_phase{phase="Pending"} > 0 for 10min | Warning |
- Your High error rate alarm fires. You check Grafana: error rate is 0%. What inputs to the alarm might differ from the dashboard query?
- You add a new feature that doubles latency. The SLO is still met, but users complain. What does this tell you about SLOs alone?
- An alarm fires at 3 AM. The runbook says "check CloudWatch." What should the runbook actually say?
Phase 3.5 — Chaos engineering
What to build
Install LitmusChaos or Chaos Mesh on the cluster. Create these experiments:
| Experiment | Hypothesis | What to observe |
|---|---|---|
| Kill one Receipt pod | ALB stops routing to it; remaining pod handles all traffic. | ALB target health, error rate, request latency |
| Kill ALL Receipt pods | Deployment controller recreates them. Downtime = time to start + pass readiness. | Downtime duration, 503 responses, ALB metrics |
| Fill the pod\'s memory | OOMKill occurs. Container restarts. No data loss (receipts in S3). | OOMKill event, restart count, any failed writes |
| Remove S3 IAM policy from Pod Identity | All uploads fail with AccessDenied. Pods remain running and Ready. | App logs, CloudTrail, error rate spike |
| Cordone one node, drain pods | Pods 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.
- You kill all pods. The Deployment controller recreates them. Why might the new pods fail readiness checks?
- You remove the S3 IAM policy. The app logs AccessDenied. ALB health checks continue to pass. Why?
- Is your Zero successful uploads alarm sensitive enough to catch the S3 policy removal?
Phase 3.6 — Runbook
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"
- What already worked? (DNS? TLS? ALB reachable?)
- Check ALB target health: are targets healthy?
- Check pod status:
kubectl get pods -n receipt - Check recent events:
kubectl describe deployment receipt-api -n receipt - Check CloudWatch / Grafana: what changed in the last 15 minutes?
Incident 2 — "Receipt uploads fail with 500"
- What changed recently? (Check Argo CD sync status, GitOps commits)
- Check app logs: filter for ERROR level
- Verify Pod Identity: does the ServiceAccount still have the IAM association?
- Check S3 bucket: does the bucket exist? Is the policy intact?
- Check VPC endpoints: can the pod reach the S3 endpoint?
- Check CloudTrail: who changed what? When?
Incident 3 — "Pods stuck Pending after scale-up"
- Describe a pending pod:
kubectl describe pod <name>— read the Events section - Is it a node capacity issue? (Insufficient CPU/memory → add nodes)
- Is it a subnet IP issue? (VPC CNI exhausted subnet IPs)
- Is it a taint/affinity mismatch?
- 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.
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
Notes to re-read
- Docker §9 (Compose — the whole chapter, especially the everyday command loop)
- Docker §11 (the complete lifecycle trace)
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
- You set S3_ENDPOINT=http://localhost:4566. The app resolves localhost. Which network namespace does this refer to?
- You use 127.0.0.1:8080 for the published port. Why NOT 0.0.0.0:8080?
- 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
Notes to re-read
- AWS §8 (ECS — the whole chapter, especially task execution role vs task role, awsvpc networking, task definition revisioning)
What to build
A new Terraform root terraform/ecs-stage/ that creates:
| Resource | Purpose |
|---|---|
aws_ecs_cluster.receipt | Fargate cluster (no EC2 instances) |
aws_ecs_task_definition.receipt | Same image digest as production, Fargate CPU/memory |
aws_ecs_service.receipt | Desired count 1, Fargate launch type |
aws_iam_role.ecs_execution | Pulls from ECR, writes to CloudWatch |
aws_iam_role.ecs_task | Same S3 permissions as EKS pod role |
aws_lb_target_group.receipt | ALB target group with health check |
aws_lb_listener_rule.receipt | Route stage-api.example.com → ECS target group |
Key learning differences from EKS
| Aspect | EKS (Project 1) | ECS (this phase) |
|---|---|---|
| Worker nodes | You manage EC2 (or Fargate profiles) | Fargate abstracts all nodes |
| Deployment model | Helm + Argo CD GitOps | ECS service + task definition revision |
| Image identity | Node role pulls image; Pod Identity for runtime | Task execution role pulls image; task role for runtime |
| Networking | Pod gets VPC CNI IP | Task gets ENI + awsvpc IP |
| Health checks | Kubernetes liveness/readiness probes | ALB target-group health check + optional ECS container health |
| Rolling update | Deployment controller via ReplicaSets | ECS service deployment circuit breaker |
Decision checkpoint
| Decision | Why 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. |
- Your ECS task keeps stopping with CannotPullContainerError. Which IAM role is wrong? Execution or task?
- The task runs but curl returns 503. The container is healthy. What is the first thing to check?
- ECS deployment circuit breaker vs Argo CD sync. Both handle failed rollouts. How do their approaches differ?
Phase 4.3 — Multi-arch image builds
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
- Your CI builds linux/arm64 on a GitHub Actions ubuntu-latest runner (which is amd64). How does it work?
- 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
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
- PR code is untrusted input (AWS §11). Never give a PR branch workflow production AWS credentials.
- Build once, promote the same digest (Docker §1). The dev and prod builds should produce identical digests. Only the tag differs.
- Environment gates (AWS §11). The production-build environment requires approved reviewers before the job runs.
- A developer opens a PR from a fork. The PR workflow includes configure-aws-credentials. Can the fork access your AWS account?
- What ensures the production image is the same bytes as the tested dev image?
- What is the difference between environment: production-build and a branch protection rule? Which one controls AWS credential access?
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
Notes to re-read
- K8s §1 (reconciliation loops — the thermostat analogy, level-based not edge-triggered)
- K8s §3 (controller-manager — the loop runner)
- K8s §5 (self-healing trace — what a controller does)
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:
| Field | Meaning |
|---|---|
watch | Which pods to monitor (namespace + label selector) |
triggers | What failure conditions to detect and what action to take |
evidence | What to capture when a failure is detected |
actions | Recovery actions and their thresholds |
notifications | Where 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+."
- Why is the operator using spec and status fields? (K8s §1 — Principle #4)
- This CRD describes a single operator instance. What if you wanted to respond to failures across ALL namespaces?
- 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
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
Notes to re-read
- K8s §1 (the controller pattern: observe, compare, act, repeat)
- K8s §5 (the self-healing trace)
- K8s §9 (cordon, eviction, QoS)
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)
- 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)
- Idempotent actions: Calling deletePod twice should be safe. The second delete on an already-gone pod should not fail the reconciliation.
- 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
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.
- The pod is in CrashLoopBackOff. RestartCount is 5. The current container logs are empty. Where do you get the logs from the previous crash?
- 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
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.
- You delete a pod (captureAndRestart). The new pod has the SAME image. What makes you think it won\'t crash again?
- You cordon a node. Existing pods keep running. New pods go elsewhere. What happens if the cluster has no spare capacity?
- 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
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
- The operator has delete on pods in all namespaces. Is that too broad? How would you scope it to only the receipt namespace?
- 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
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:
| Test | How to trigger | Expected response |
|---|---|---|
| OOMKill | Set memory limit to 32Mi, send a large PDF | CR status updated with incident evidence. Pod restarts (kubelet). Operator captures logs. |
| CrashLoopBackOff | Deploy image with deliberate crash on startup | After 5 restarts, operator deletes pod. Deployment recreates it. If keeps crashing, cordon triggered. |
| ImagePullBackOff | Deploy with nonexistent image tag | Notify via Slack (no deletion). |
| Repeated failures on same node | CrashLoopBackOff repeatedly, scheduled to same node | After 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:
- Which tool owns each resource\'s desired state? (Terraform, Argo CD, the operator, a human with kubectl?)
- Which identity performs each action? (Human IAM role? CI OIDC role? Pod Identity? Node role? Execution role?)
- What evidence proves it worked? (CloudWatch log? Argo CD sync status? Grafana dashboard? CR status?)
- How do you roll back? (Git revert? Terraform destroy/recreate? Helm rollback?)
- 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"):
- Name: did it resolve?
- Route: can packets reach and return?
- Filter: do network controls permit the flow?
- Identity: which credentials are actually used?
- Authorization: is the exact action/resource allowed?
- Health: is the target ready, not merely alive?
- Ownership: which controller should make this state true?
- 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":
- Start with the problem the project solved (not the tools).
- Describe one decision you made and why (e.g., "We chose Pod Identity over static access keys because...").
- 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...").
- 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
- Build nightly: set a GitHub Actions schedule to rebuild the Receipt API image daily. If dependencies have changed, you catch it early.
- Terraform plan weekly: run
terraform planagainst your platform. If nothing drifts, you have evidence of stability. If something drifted, you have a debugging exercise. - 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.
- 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).
- 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.