A practical Platform Engineering capstone: from infrastructure provisioning to application deployment, persistent storage, autoscaling, validation, and cleanup

Author: Jamiu Bakre

Project: AWS Kubernetes Platform Engineering Capstone

Cloud Provider: Amazon Web Services (AWS)

Infrastructure as Code: Terraform

Containerization: Docker

Container Orchestration: Kubernetes on Amazon EKS

Container Registry: Amazon Elastic Container Registry (ECR)

Application Load Balancing: AWS Application Load Balancer (ALB)

Database: PostgreSQL

Primary AWS Region: eu-west-1

Kubernetes Cluster: platform-capstone-prod

1. Introduction

One of the things I wanted to achieve with this capstone was to go beyond deploying an application to a cloud server. I wanted to build a platform that demonstrated how cloud infrastructure, containers, Kubernetes, persistent storage, networking, security, monitoring, and automation work together in a production-oriented environment.

The project started with a familiar application architecture: a frontend, a backend API, and a PostgreSQL database. Rather than deploying these components manually to individual servers, I used Terraform to define the cloud infrastructure and Kubernetes to manage the application workloads.

The goal was to create an environment that could be provisioned consistently, support application deployment, recover from ordinary workload failures, scale when demand increased, and provide a clear path for future improvements.

This was also an opportunity to bring together several areas of cloud and platform engineering:

  • Infrastructure as Code with Terraform.
  • AWS networking and private application infrastructure.
  • Container image creation with Docker.
  • Image storage and distribution through Amazon ECR.
  • Kubernetes workload orchestration with Amazon EKS.
  • Persistent application data using PostgreSQL, PersistentVolumes, and PersistentVolumeClaims.
  • External application access through an AWS Application Load Balancer.
  • Health monitoring, resource requests and limits, and Horizontal Pod Autoscaling.
  • Infrastructure validation, application testing, documentation, and cleanup.

The important part of this project was not simply getting the application to run. It was understanding how each component depends on the others and how to validate the system as a whole.

Project objectives

The main objectives were to:

  1. Provision the AWS infrastructure using Terraform.
  2. Create a VPC with public and private subnets.
  3. Deploy an Amazon EKS cluster with managed worker nodes.
  4. Build and publish the frontend and backend container images to Amazon ECR.
  5. Deploy the frontend, backend API, and PostgreSQL database to Kubernetes.
  6. Expose the frontend through an AWS Application Load Balancer.
  7. Configure persistent database storage.
  8. Implement application health probes and resource management.
  9. Configure and validate Horizontal Pod Autoscaling.
  10. Test database recovery after a PostgreSQL Pod restart.
  11. Validate the Terraform configuration and maintain the project in GitHub.
  12. Clean up the deployed infrastructure while preserving the Terraform state in Amazon S3.

[Screenshot: Capstone assignment and objectives.]

[Screenshot: The GitHub repository homepage showing the project files.]

2. Understanding the architecture

Before writing Terraform or Kubernetes manifests, it is useful to understand how the application and infrastructure fit together.

The platform has three application components:

  • Frontend: serves the user-facing web application.
  • Backend: provides application logic and API endpoints.
  • PostgreSQL: stores application data and provides persistent database storage.

The frontend and backend run as Kubernetes Deployments. PostgreSQL runs as a StatefulSet with persistent storage. Kubernetes Services provide stable internal networking, while an Ingress resource instructs the AWS Load Balancer Controller to create an Application Load Balancer for external access.

High-level architecture

 Internet | v AWS Application Load Balancer | v Kubernetes Ingress | v Frontend Service | v Frontend Pods | API requests | v Backend Service | v Backend Pods | v PostgreSQL Service | v PostgreSQL StatefulSet | v PersistentVolumeClaim | v Encrypted EBS gp3 Volume AWS Infrastructure managed with Terraform ------------------------------------------- VPC ├── Public subnets ├── Private subnets ├── Internet Gateway ├── NAT Gateways ├── Route tables ├── Security groups ├── Amazon EKS cluster ├── Managed worker node group └── Amazon ECR repositories Container delivery ------------------ Application source code | v Docker | v Amazon ECR | v Amazon EKS 

Important networking distinction: the deployed Ingress configuration routed public traffic to the frontend on HTTP port 80. The backend and PostgreSQL were intended to remain internal services. The diagram illustrates the application's logical communication path; actual browser-to-backend communication depends on the frontend configuration, such as an Nginx API proxy or additional Ingress rules.

[Screenshot: Architecture diagram.]

How the components work together

When a user visits the application, the request reaches the Application Load Balancer. The ALB routes traffic according to the Kubernetes Ingress configuration. In this implementation, the root path / routes to the frontend service.

The frontend runs in one or more Pods. When it needs backend functionality, it communicates with the backend through the configured API endpoint. The backend communicates with PostgreSQL through an internal Kubernetes Service.

Kubernetes continuously monitors the declared workloads. If a frontend or backend Pod fails, the corresponding Deployment can create a replacement. PostgreSQL uses a StatefulSet and a PersistentVolumeClaim so that database storage can outlive an individual Pod.

Terraform manages the AWS infrastructure, while Kubernetes manages the application workloads. Keeping these responsibilities separate makes the platform easier to maintain.

3. Technology stack

Technology Purpose
AWS VPC Isolated cloud networking
Public and private subnets Network separation and workload placement
Internet Gateway Internet connectivity for public resources
NAT Gateways Outbound connectivity from private subnets
Terraform Reproducible infrastructure provisioning
Amazon EKS Managed Kubernetes control plane
EKS managed node group Worker compute capacity
Amazon ECR Container image registry
Docker Application containerization
Kubernetes Deployment Frontend and backend workload management
Kubernetes Service Stable internal networking
StatefulSet Stable identity and storage association for PostgreSQL
PersistentVolumeClaim Request for persistent storage
Amazon EBS CSI driver Provisioning and attachment of EBS storage
AWS Load Balancer Controller AWS load balancer integration with Kubernetes
Application Load Balancer Public HTTP entry point
Metrics Server Kubernetes resource usage metrics
Horizontal Pod Autoscaler Automatic replica scaling
Amazon S3 Remote Terraform state storage
Git and GitHub Version control and project delivery

[Screenshot: Terraform, Docker, Kubernetes and AWS tools used in the project.]

Phase 1: Preparing the development environment

4. Tools and prerequisites

Before provisioning infrastructure, the local environment needs the tools required to create cloud resources, build containers, and interact with Kubernetes.

The main tools used in this project were:

  • AWS CLI.
  • Terraform.
  • Docker.
  • kubectl.
  • Git.
  • Helm.
  • Graphviz, for generating a Terraform dependency graph.

The development environment was Ubuntu under Windows Subsystem for Linux (WSL).

4.1 Verify the installed tools

Run the following commands in your terminal:

aws --version terraform version docker --version kubectl version --client helm version git --version 

If you intend to generate an infrastructure dependency graph, verify Graphviz as well:

dot -V 

If Graphviz is not installed in Ubuntu:

sudo apt update sudo apt install graphviz 

4.2 Configure AWS access

Configure the AWS CLI using your approved AWS credentials:

aws configure 

The CLI prompts for an access key, secret key, default region, and output format. Use an IAM identity with the permissions needed for the project. Never commit AWS credentials to Git.

Verify which account is active:

aws sts get-caller-identity 

The output identifies the AWS account and the IAM identity used for subsequent operations.

Confirm the intended region:

aws configure get region 

For this project, the primary region was:

eu-west-1 

If necessary, set a shell variable for the current session:

export AWS_REGION="eu-west-1" export AWS_DEFAULT_REGION="$AWS_REGION" 

Why this matters: AWS commands can create or destroy real resources. Confirming the account and region before running Terraform prevents accidental changes to another environment.

Phase 2: Organizing the project

5. Repository structure

A well-organized repository separates infrastructure configuration, reusable Terraform modules, Kubernetes manifests, and application source code.

The following is a representative structure for this project. Adjust the file names to match the actual repository before publishing it.

platform-capstone/ ├── terraform/ │ ├── bootstrap/ │ │ └── main.tf │ ├── environments/ │ │ └── prod/ │ │ ├── main.tf │ │ ├── variables.tf │ │ ├── outputs.tf │ │ ├── providers.tf │ │ ├── versions.tf │ │ └── backend.tf │ └── modules/ │ ├── vpc/ │ ├── ecr/ │ └── eks/ ├── app/ │ ├── frontend/ │ │ ├── Dockerfile │ │ └── nginx.conf │ └── backend/ │ ├── Dockerfile │ ├── package.json │ └── server.js ├── k8s/ │ ├── namespace.yaml │ ├── postgres-secret.example.yaml │ ├── postgres-service.yaml │ ├── postgres-statefulset.yaml │ ├── frontend-deployment.yaml │ ├── frontend-service.yaml │ ├── backend-deployment.yaml │ ├── backend-service.yaml │ ├── hpa.yaml │ └── ingress.yaml ├── docs/ │ └── screenshots/ ├── .gitignore └── README.md 

The exact repository may use a different layout. The key principle is to keep reusable infrastructure modules separate from the environment-specific configuration.

5.1 Why separate Terraform modules?

Modules allow infrastructure components to be defined once and reused.

For example, the VPC module can own the VPC, subnets, routes, and networking resources. The ECR module can create repositories. The EKS module can manage the Kubernetes cluster and worker nodes.

The production environment then connects these modules by passing outputs from one module into another.

This reduces duplication and makes the infrastructure easier to review and extend.

[Screenshot 6: Insert a terminal or GitHub view of the project directory structure.]

Phase 3: Provisioning infrastructure with Terraform

6. Terraform design

Terraform was used to define the AWS resources declaratively. Instead of creating resources individually through the AWS Console, the configuration describes the desired infrastructure and allows Terraform to determine the required changes.

The core infrastructure consisted of:

  • A VPC.
  • Public and private subnets across two Availability Zones.
  • Internet and NAT connectivity.
  • Route tables and security controls.
  • Amazon EKS.
  • Managed worker nodes.
  • Amazon ECR repositories.

The deployed worker nodes were two t3.medium instances. The EKS cluster was named platform-capstone-prod and used Kubernetes version 1.35.

6.1 Configure the Terraform provider

A representative provider configuration is:

terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = var.aws_region default_tags { tags = { Project = "platform-capstone" Environment = "prod" ManagedBy = "Terraform" } } } variable "aws_region" { description = "AWS region for the platform" type = string default = "eu-west-1" } 

The AWS provider version constraint should match the version selected and tested in the repository's lock file. Do not upgrade a working provider configuration without reviewing the plan.

Default tags make resources easier to identify for operations, auditing, and cost analysis.

6.2 Define the infrastructure modules

A simplified root configuration could look like this:

module "vpc" { source = "../../modules/vpc" name = "platform-capstone-prod" vpc_cidr = "10.0.0.0/16" availability_zones = ["eu-west-1a", "eu-west-1b"] public_subnet_cidrs = [ "10.0.1.0/24", "10.0.2.0/24" ] private_subnet_cidrs = [ "10.0.11.0/24", "10.0.12.0/24" ] } module "ecr" { source = "../../modules/ecr" repositories = [ "platform-capstone-prod/frontend", "platform-capstone-prod/backend" ] } module "eks" { source = "../../modules/eks" cluster_name = "platform-capstone-prod" kubernetes_version = "1.35" vpc_id = module.vpc.vpc_id private_subnet_ids = module.vpc.private_subnet_ids } 

This is an illustrative module interface, not a complete drop-in configuration. The variables and outputs must correspond to those defined in the actual modules.

The EKS module also needs to define the cluster IAM permissions, managed node group, networking configuration, and any required access entries or authentication configuration.

6.3 Build the VPC

The VPC provides the network boundary for the platform.

The project used two public subnets and two private subnets across two Availability Zones.

Public subnets support resources that require a route to the Internet Gateway, such as an internet-facing ALB. Private subnets are appropriate for worker nodes and other internal workloads.

The public route table typically includes a default route to the Internet Gateway:

resource "aws_route" "public_internet" { route_table_id = aws_route_table.public.id destination_cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this.id } 

Private subnets can use NAT Gateways for outbound connectivity where required. A NAT Gateway is not a substitute for security groups or application authentication; it provides network address translation for outbound connections.

This design improves network separation, but it has a cost implication. NAT Gateways incur hourly and data-processing charges. The capstone used two NAT Gateways, one for each Availability Zone.

[Screenshot: The Terraform plan showing the VPC, subnet, route, and gateway resources.]

[Screenshot: Insert the AWS VPC console showing the public and private subnets across two Availability Zones.]

6.4 Create Amazon ECR repositories

Amazon ECR stores the Docker images that Kubernetes pulls when starting application Pods.

The two repositories were:

platform-capstone-prod/frontend platform-capstone-prod/backend 

A Terraform module can create them using aws_ecr_repository. The following is a representative resource:

resource "aws_ecr_repository" "frontend" { name = "platform-capstone-prod/frontend" image_tag_mutability = "IMMUTABLE" image_scanning_configuration { scan_on_push = true } encryption_configuration { encryption_type = "AES256" } tags = { Project = "platform-capstone" } } 

Repeat the configuration for the backend or use a reusable module.

Immutable tags help prevent a tag from being silently reassigned to another image. If you use immutable tags, publish new version tags for each build instead of repeatedly pushing to the same tag.

6.5 Create the EKS cluster and worker nodes

Amazon EKS manages the Kubernetes control plane. The worker nodes provide compute capacity for application Pods.

The platform used:

  • Cluster name: platform-capstone-prod.
  • Kubernetes version: 1.35.
  • Managed worker nodes: two t3.medium instances.
  • Worker placement: private subnets.
  • VPC networking: the VPC created by Terraform.

The EKS module must define a cluster IAM role, node IAM role, networking, cluster access, and the managed node group. It should also establish appropriate security group rules.

The control plane and worker nodes have different responsibilities. The control plane manages the Kubernetes API, scheduling, and cluster state. The worker nodes run the actual application containers.

[Screenshot: The EKS console showing platform-capstone-prod and the active node group.]

6.6 Initialize, validate, plan, and apply

Run Terraform commands from the correct root directory:

cd ~/platform-capstone/terraform/environments/prod terraform fmt -recursive terraform init terraform validate terraform plan 

Review the plan carefully before applying it:

terraform apply 

Terraform displays the proposed actions and requests confirmation. Review resource creation, replacement, and destruction before approving.

After deployment, verify the state:

terraform state list terraform output 

The exact outputs depend on the repository configuration.

Why this sequence matters:

  • terraform fmt standardizes formatting.
  • terraform init initializes providers, modules, and the backend.
  • terraform validate checks the configuration for structural errors.
  • terraform plan previews the intended changes.
  • terraform apply executes the approved plan.
  • terraform state list helps verify which resources Terraform is tracking.

A successful terraform validate does not prove that AWS resources can be created or that the application works. Those require deployment and runtime testing.

7. Remote Terraform state with Amazon S3

Terraform state records the mapping between Terraform resource definitions and the infrastructure Terraform manages.

For this project, the remote state bucket was:

platform-capstone-terraform-state-911167903075 

The state key was:

platform-capstone/prod/terraform.tfstate 

The backend was configured for eu-west-1, with S3 versioning and public access blocking. The configuration used server-side encryption and S3-native state locking through use_lockfile.

A representative backend block is:

terraform { backend "s3" { bucket = "platform-capstone-terraform-state-911167903075" key = "platform-capstone/prod/terraform.tfstate" region = "eu-west-1" encrypt = true use_lockfile = true } } 

The bucket must exist before Terraform can initialize this backend. For a fresh deployment, the bootstrap process should create and secure the bucket first.

Why remote state matters

Remote state provides a central location for Terraform's resource mapping. It is particularly useful when working across different sessions or collaborating with other engineers.

State files may contain sensitive values. Restrict access through IAM, prevent public access, enable versioning, and never commit state files to a public Git repository.

After the capstone teardown, the state file remained in S3. This preserves the deployment record, but it does not mean the AWS infrastructure is still running.

[Screenshot 11: Insert the Terraform backend configuration with sensitive details excluded if necessary.]

[Screenshot 12: Insert the S3 bucket properties showing versioning, encryption, and public access blocking.]

Phase 4: Building and publishing container images

8. Why Docker was used

Docker packages an application and its runtime dependencies into a container image. The image can be built once, stored in a registry, and deployed consistently across environments.

The platform used separate images for the frontend and backend:

frontend:v2 backend:v1 

The images were stored in Amazon ECR.

Using separate images also allows each component to be deployed and updated independently.

9. Building the backend API image

The backend used Node.js and Express and listened on port 5000. It exposed liveness and readiness endpoints:

/health/live /health/ready 

The liveness endpoint indicates whether the process is alive. The readiness endpoint checks whether the application is ready to receive traffic and, in this implementation, includes a database connectivity check.

9.1 Representative backend application

The following is a simplified example of the pattern. Adapt it to the actual backend source and database library.

const express = require("express"); const app = express(); const port = process.env.PORT || 5000; app.use(express.json()); app.get("/health/live", (_req, res) => { res.status(200).json({ status: "alive" }); }); app.get("/health/ready", async (_req, res) => { try { // Replace this with a real PostgreSQL connectivity check. res.status(200).json({ status: "ready" }); } catch (_error) { res.status(503).json({ status: "not-ready" }); } }); app.get("/api", (_req, res) => { res.json({ message: "Backend API is running" }); }); app.listen(port, "0.0.0.0", () => { console.log(`Backend listening on port ${port}`); }); 

Important: the example readiness handler does not actually connect to PostgreSQL. In the deployed application, readiness must query the database or use the real connection pool to verify connectivity. A successful HTTP response alone is not proof that the database is healthy.

9.2 Representative backend Dockerfile

FROM node:22-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . ENV NODE_ENV=production ENV PORT=5000 USER node EXPOSE 5000 CMD ["node", "server.js"] 

This example uses a non-root runtime user. The Node.js version must match the application's supported runtime and the version actually tested in the repository.

For a production image, also consider a .dockerignore file, dependency pinning, vulnerability scanning, and reducing unnecessary files in the image.

Example .dockerignore:

node_modules .git .env npm-debug.log coverage 

Caveat: Do not copy production credentials into the image!

10. Building the frontend image

The frontend was served using Nginx on port 8080, running as a non-root user.

For a static frontend, a multi-stage Docker build can compile the application and then copy only the production assets into the Nginx image.

10.1 Representative frontend Dockerfile

FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginxinc/nginx-unprivileged:stable-alpine COPY --from=build /app/dist/ /usr/share/nginx/html/ EXPOSE 8080 

This example assumes the frontend build output is dist/. Change the source path if the application uses a different build directory.

If the frontend needs a custom Nginx configuration, provide one compatible with the unprivileged Nginx image. Do not assume the default configuration listens on port 8080; verify it.

10.2 Build and test the images locally

Example commands:

docker build -t platform-frontend:v2 ./app/frontend docker build -t platform-backend:v1 ./app/backend 

Check the image sizes:

docker image ls 

The capstone assessment included a target of keeping both images below 150 MB. Check the actual image sizes rather than assuming the target was met.

Run the containers locally before publishing:

docker run --rm -p 8080:8080 platform-frontend:v2 

In another terminal, test the frontend:

curl -I http://localhost:8080/ 

For the backend:

docker run --rm -p 5000:5000 platform-backend:v1 

Then test the health endpoint:

curl -i http://localhost:5000/health/live 

Use the application's actual database configuration when testing readiness. If the database is unavailable, a correctly implemented readiness check should not claim the application is ready.

11. Push images to Amazon ECR

Before pushing, obtain the AWS account ID:

AWS_ACCOUNT_ID=$(aws sts get-caller-identity \ --query Account --output text) AWS_REGION="eu-west-1" 

Log Docker into ECR:

aws ecr get-login-password --region "$AWS_REGION" | docker login \ --username AWS \ --password-stdin \ "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" 

Define the image URIs:

FRONTEND_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/platform-capstone-prod/frontend" BACKEND_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/platform-capstone-prod/backend" 

Tag the images:

docker tag platform-frontend:v2 "${FRONTEND_URI}:v2" docker tag platform-backend:v1 "${BACKEND_URI}:v1" 

Push them:

docker push "${FRONTEND_URI}:v2" docker push "${BACKEND_URI}:v1" 

Verify the repository images:

aws ecr describe-images \ --repository-name platform-capstone-prod/frontend \ --region "$AWS_REGION" aws ecr describe-images \ --repository-name platform-capstone-prod/backend \ --region "$AWS_REGION" 

The EKS worker nodes must have permission to pull these images. This normally involves the appropriate ECR permissions on the node IAM role or another configured image-pull identity.

Why use ECR? It provides a private registry integrated with AWS IAM. It separates the process of building an image from the process of deploying that image.

[Screenshot: Insert the ECR frontend and backend repository with its image tag.]

Phase 5: Configuring access to Amazon EKS

12. Connect kubectl to the cluster

After Terraform creates the EKS cluster, configure kubectl to use it...