Week 25: Capstone, Part 1 — Plan, Provision & Pipeline

The capstone spans this week and next — no new tools, just the entire course wired together into one working system. This week: design the architecture, provision real Terraform-managed AWS infrastructure with tagging and remote state, add a managed database with backups configured correctly, and build the CI/CD pipeline that tests, scans and pushes an image. Week 26 picks up from here — deploying it, laying observability and security on top, and defending the finished system end to end.

Module 22 of 22 Week 25 of 26 ~8–10 Hours Capstone Project

By the end of this week, you'll be able to

  • Design and justify a capstone architecture that draws on the full 24 weeks behind it
  • Provision a VPC, compute, a container registry and a managed database entirely through tagged, versioned Terraform
  • Build a GitHub Actions pipeline that tests, scans, builds and pushes a container image

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 k3s cluster 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.

Scope for two weeks of real, evening-and-weekend effort, not a full production system

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.

backend.tf & provider.tf — remote state and default tags
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"
    }
  }
}
main.tf — VPC, EC2 instance, ECR repository & RDS
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.

terminal — the plan/apply loop
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform output node_public_ip
terraform output db_endpoint
Never skip the remote backend or tags, even for a capstone

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.

.github/workflows/build.yml
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.

Confirm the pipeline actually fails before you trust it

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 apply runs 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 nodes shows 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 main triggers 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.