1. Project Planning & Architecture
Before touching Terraform or YAML, write down what you're actually deploying and
where. Reuse a small containerized app you already have from earlier in this course
— a health-check API, a small CRUD service, anything with a Dockerfile,
a /healthz endpoint, and a real database dependency is enough. The
architecture doesn't need to be ambitious to be a strong capstone; it needs to be
complete, and every decision needs to be one you can defend:
- Compute target — a single-node
k3scluster on one EC2 instance is the realistic choice for a free-tier capstone: genuinely Kubernetes, without the ongoing cost of a managed EKS control plane. Note in your write-up why a production system would more likely use EKS (managed control plane, multi-AZ node groups, native IRSA) — Week 24's "could vs. should" framing applies here too. - Registry — a private, scan-on-push AWS ECR repository, matching Week 5.
- Networking — one VPC with a public subnet for the instance, matching Week 8, with a security group open only on the ports the pipeline and cluster actually need.
- Database — a real RDS Postgres instance with PITR enabled, matching Week 24, rather than a database Pod running alongside the app with no durability story.
- Deploy mechanism — decide now whether Week 26 will deploy via direct
kubectl/CI (the simpler path) or via ArgoCD GitOps from Week 17 (the more advanced path); either is a valid capstone, but the choice changes what Section 3's pipeline needs to do.
Sketch the full path a change takes before writing any code: a push to
main triggers CI, which tests and scans the app, builds and pushes an
image to ECR, then either CI or ArgoCD rolls that image out to the cluster. Write that
sequence down as a numbered list — it becomes both this week's pipeline job order and
the architecture diagram in Week 26's final write-up.
The strongest capstones in this course are small in surface area and correct in every layer — infrastructure, pipeline, deploy, observability, security — not large in feature scope with shortcuts taken somewhere to make the deadline. Pick the smallest app that legitimately needs a database and a deploy pipeline, and spend the saved time on the layers a reviewer actually screens for.
2. Provisioning Infrastructure with Terraform
Everything below is provisioned the Week 9–10 and Week 20 way: written as HCL, planned before applied, backed by remote state with locking, and tagged consistently per Week 23 from the very first resource.
terraform {
backend "s3" {
bucket = "codeverse-capstone-tfstate"
key = "capstone/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
project = "capstone"
managed_by = "terraform"
}
}
}
resource "aws_vpc" "capstone" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.capstone.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
}
resource "aws_subnet" "db_a" {
vpc_id = aws_vpc.capstone.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1a"
}
resource "aws_subnet" "db_b" {
vpc_id = aws_vpc.capstone.id
cidr_block = "10.0.3.0/24"
availability_zone = "us-east-1b"
}
resource "aws_db_subnet_group" "capstone" {
subnet_ids = [aws_subnet.db_a.id, aws_subnet.db_b.id] # RDS requires 2+ AZs
}
resource "aws_security_group" "cluster" {
name = "capstone-cluster-sg"
vpc_id = aws_vpc.capstone.id
ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.admin_cidr] }
ingress { from_port = 6443 to_port = 6443 protocol = "tcp" cidr_blocks = [var.admin_cidr] }
ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
}
resource "aws_instance" "k3s_node" {
ami = "ami-0c101f26f147fa7fd" # Ubuntu 22.04 LTS, us-east-1
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.cluster.id]
key_name = var.key_pair_name
associate_public_ip_address = true
user_data = file("${path.module}/install-k3s.sh")
}
resource "aws_ecr_repository" "app" {
name = "capstone-app"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration { scan_on_push = true }
}
resource "aws_db_instance" "app" {
identifier = "capstone-db"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t3.micro"
allocated_storage = 20
db_subnet_group_name = aws_db_subnet_group.capstone.name
backup_retention_period = 7 # enables PITR -- Week 24
skip_final_snapshot = true # only for a capstone you'll tear down
username = var.db_username
password = var.db_password
}
output "node_public_ip" { value = aws_instance.k3s_node.public_ip }
output "db_endpoint" { value = aws_db_instance.app.endpoint }
backup_retention_period = 7 is what makes point-in-time recovery
possible at all — without it, RDS keeps no continuous transaction log to replay
against, and a "backup" would only ever mean the last snapshot boundary, not any
specific moment. image_tag_mutability = "IMMUTABLE" combined with
scan_on_push forecloses two separate problems at once: no tag can ever
silently point to a different image later, and nothing reaches the registry unscanned
regardless of how it got pushed.
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform output node_public_ip
terraform output db_endpoint
Wiring up S3 + DynamoDB locking and default_tags from the very first resource is what proves you can build infrastructure a team could actually collaborate on and account for, not just infrastructure that happens to work on one laptop with an untagged bill nobody could attribute later. It's also the difference that matters if your laptop dies mid-capstone and you need to pick it back up from a different machine.
3. The CI/CD Pipeline
With infrastructure in place, the pipeline is where Weeks 3–7 and Week 20 converge: test and scan the app, build and push an image, and gate every stage on the previous one succeeding.
name: Build, Test & Scan
on:
push:
branches: [main]
env:
ECR_REPOSITORY: capstone-app
AWS_REGION: us-east-1
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./run-tests.sh # your app's own test suite from earlier weeks
build-scan-push:
needs: test
runs-on: ubuntu-latest
outputs:
image: ${{ steps.image.outputs.image }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} # OIDC -- no long-lived keys
aws-region: ${{ env.AWS_REGION }}
- uses: aws-actions/amazon-ecr-login@v2
id: ecr-login
- name: Build image
run: docker build -t ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"
ignore-unfixed: true
- name: Push image
run: docker push ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
- id: image
run: echo "image=${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
This pipeline deliberately stops right after the push — Week 26 picks up from here
and adds the deploy stage, whichever mechanism you chose in Section 1. Two decisions
already in this job are worth defending explicitly in your final write-up: OIDC
federation via role-to-assume means the runner never holds a standing
AWS credential at all, and exit-code: "1" paired with
ignore-unfixed: true means the scan is a real, enforced gate rather than
decoration, without blocking every merge on a CVE nobody can actually patch yet.
A pipeline you've never watched fail is a pipeline you're only assuming is a real gate. Before ending the week, deliberately break a test and deliberately reference an image with a known CVE, and confirm both stop the pipeline before the push step — verifying the gate now is far cheaper than discovering it was cosmetic during Week 26's deploy.
4. End-of-Week Checkpoint
Before moving into Week 26, confirm all of the following are true and working:
- ✅
terraform applyruns clean from an empty state, with remote state locking and consistent tags on every resource. - ✅ The k3s node is reachable over SSH and
kubectl get nodesshows it Ready. - ✅ The RDS instance is reachable from the k3s node's security group, with a 7-day backup retention period confirmed in the console.
- ✅ A push to
maintriggers the pipeline, and it fails correctly when you deliberately break a test or reference a known-vulnerable image. - ✅ A real image tag exists in ECR, pushed by the pipeline using OIDC-assumed credentials, not a personal access key.
With all five true, the foundation is solid enough to build the rest of the capstone on top of in Week 26 — deploying the app, layering on observability and security, and writing the final defense of every decision made across both weeks.