DEV Community

Cover image for Capstone Project: Building an End-to-End AWS Kubernetes Platform with Terraform, Docker, Amazon EKS, and PostgreSQL
BAKRE JAMIU
BAKRE JAMIU

Posted on

Capstone Project: Building an End-to-End AWS Kubernetes Platform with Terraform, Docker, Amazon EKS, and PostgreSQL

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.

Frontend


Backend


Backend health Status


Live status

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

dot -V
Enter fullscreen mode Exit fullscreen mode

If Graphviz is not installed in Ubuntu:

sudo apt update
sudo apt install graphviz
Enter fullscreen mode Exit fullscreen mode

4.2 Configure AWS access

Configure the AWS CLI using your approved AWS credentials:

aws configure
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Confirm the intended region:

aws configure get region
Enter fullscreen mode Exit fullscreen mode

For this project, the primary region was:

eu-west-1
Enter fullscreen mode Exit fullscreen mode

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

export AWS_REGION="eu-west-1"
export AWS_DEFAULT_REGION="$AWS_REGION"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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: 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Review the plan carefully before applying it:

terraform apply
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-911xxxx03075
Enter fullscreen mode Exit fullscreen mode

The state key was:

platform-capstone/prod/terraform.tfstate
Enter fullscreen mode Exit fullscreen mode

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-9111xxxx3075"
    key          = "platform-capstone/prod/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check the image sizes:

docker image ls
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In another terminal, test the frontend:

curl -I http://localhost:8080/
Enter fullscreen mode Exit fullscreen mode

For the backend:

docker run --rm -p 5000:5000 platform-backend:v1
Enter fullscreen mode Exit fullscreen mode

Then test the health endpoint:

curl -i http://localhost:5000/health/live
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Tag the images:

docker tag platform-frontend:v2 "${FRONTEND_URI}:v2"
docker tag platform-backend:v1 "${BACKEND_URI}:v1"
Enter fullscreen mode Exit fullscreen mode

Push them:

docker push "${FRONTEND_URI}:v2"
docker push "${BACKEND_URI}:v1"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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: 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:

aws eks update-kubeconfig \
  --region eu-west-1 \
  --name platform-capstone-prod
Enter fullscreen mode Exit fullscreen mode

Verify the active context:

kubectl config current-context
kubectl cluster-info
Enter fullscreen mode Exit fullscreen mode

Check the worker nodes:

kubectl get nodes -o wide
Enter fullscreen mode Exit fullscreen mode

The expected result was two worker nodes in the Ready state.

The Ready status means Kubernetes considers the nodes healthy enough to participate in scheduling. It does not prove that every application or dependency is working.

[Screenshot: Insert kubectl get nodes -o wide showing both Ready nodes.]

13. Verify cluster add-ons

The cluster included the following add-ons and supporting components:

  • VPC CNI.
  • CoreDNS.
  • kube-proxy.
  • Amazon EBS CSI driver.
  • EKS Pod Identity Agent.
  • Metrics Server.
  • AWS Load Balancer Controller.

Verify EKS-managed add-ons:

aws eks list-addons \
  --cluster-name platform-capstone-prod \
  --region eu-west-1
Enter fullscreen mode Exit fullscreen mode

Check Kubernetes system Pods:

kubectl get pods -n kube-system
Enter fullscreen mode Exit fullscreen mode

Check Metrics Server:

kubectl top nodes
Enter fullscreen mode Exit fullscreen mode

If metrics are not yet available, investigate the Metrics Server deployment and its connectivity to the kubelets. Installing an HPA resource alone does not guarantee that resource metrics are available.


Phase 6: Deploying the application to Kubernetes

14. Create a dedicated namespace

A namespace groups the application's Kubernetes resources and makes them easier to inspect and remove.

Create namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: platform
Enter fullscreen mode Exit fullscreen mode

Apply it:

kubectl apply -f namespace.yaml
Enter fullscreen mode Exit fullscreen mode

Verify:

kubectl get namespaces
Enter fullscreen mode Exit fullscreen mode

The application resources were deployed into the platform namespace.

15. Configure application secrets

The backend and PostgreSQL need database connection settings. Credentials should not be placed directly in a Docker image or committed to a public repository.

A Kubernetes Secret is one possible mechanism for supplying sensitive values. For a production system, consider a managed secret store such as AWS Secrets Manager and an appropriate integration.

A sample Secret template is shown below for illustration only:

apiVersion: v1
kind: Secret
metadata:
  name: postgres-secret
  namespace: platform
type: Opaque
stringData:
  POSTGRES_DB: appdb
  POSTGRES_USER: appuser
  POSTGRES_PASSWORD: REPLACE_WITH_A_SECRET
Enter fullscreen mode Exit fullscreen mode

Do not apply this example with a real production password stored in source control. Populate sensitive values through a secure process, and ensure the Secret is not committed to Git.

A .gitignore can exclude local secret files:

.env
.env.*
!*.env.example
*secret*.yaml
terraform.tfstate
terraform.tfstate.*
.terraform/
Enter fullscreen mode Exit fullscreen mode

Review the ignore rules carefully. The last rule can exclude filenames containing secret; adapt it if your repository intentionally tracks safe example templates. .gitignore does not remove files already committed to Git.


Phase 7: Deploying PostgreSQL with persistent storage

16. Why PostgreSQL uses a StatefulSet

A database has different operational needs from a stateless frontend.

A frontend Pod can generally be replaced without preserving local filesystem state. A database must preserve its data when a Pod is recreated or moved.

The capstone used a PostgreSQL StatefulSet with a 20 GiB encrypted gp3 EBS-backed PersistentVolumeClaim.

The important Kubernetes storage concepts are:

  • PersistentVolume (PV): represents storage available to a workload.
  • PersistentVolumeClaim (PVC): requests storage with specified capacity and access requirements.
  • StorageClass: defines how storage is dynamically provisioned.
  • StatefulSet: manages Pods with stable identities and storage associations.
  • EBS CSI driver: allows Kubernetes to provision and manage Amazon EBS volumes.

A PVC is a request for storage, not the data itself. When dynamically provisioned, Kubernetes and the CSI driver arrange for an appropriate volume to be created and bound to the claim.

17. Understand the storage lifecycle

The storage flow is:

PostgreSQL StatefulSet
          |
          v
 volumeClaimTemplates
          |
          v
 PersistentVolumeClaim
          |
          v
 StorageClass (gp3)
          |
          v
 Amazon EBS CSI Driver
          |
          v
 Encrypted EBS Volume
Enter fullscreen mode Exit fullscreen mode

The volumeClaimTemplates section of a StatefulSet allows Kubernetes to create a PVC for each StatefulSet replica.

For a single PostgreSQL replica, the claim is typically named using the StatefulSet name and ordinal, for example:

data-postgres-0
Enter fullscreen mode Exit fullscreen mode

The actual name depends on the StatefulSet and claim-template names.

17.1 Example StorageClass

A representative StorageClass for encrypted gp3 storage is:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: postgres-gp3
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
parameters:
  type: gp3
  encrypted: "true"
reclaimPolicy: Retain
Enter fullscreen mode Exit fullscreen mode

This is a reference example. If the cluster already has an appropriate StorageClass, use the actual one rather than creating a duplicate.

The WaitForFirstConsumer binding mode allows volume provisioning to account for the scheduling requirements of the consuming Pod.

The reclaim policy deserves attention:

  • Delete can remove the underlying dynamically provisioned volume when the claim is released, depending on the lifecycle and provisioner.
  • Retain preserves the underlying volume for manual recovery and cleanup.

Retaining storage can reduce the chance of accidental data loss, but it can also leave billable volumes behind. Choose the policy deliberately.

17.2 Example PostgreSQL StatefulSet

The following example illustrates the key configuration. Use the exact image version, credentials, probes, and initialization settings that your application requires.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: platform
spec:
  serviceName: postgres
  replicas: 1

  selector:
    matchLabels:
      app: postgres

  template:
    metadata:
      labels:
        app: postgres

    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432

          env:
            - name: POSTGRES_DB
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_DB

            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_USER

            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_PASSWORD

          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "1Gi"

          readinessProbe:
            exec:
              command:
                - sh
                - -c
                - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
            initialDelaySeconds: 10
            periodSeconds: 10

          livenessProbe:
            exec:
              command:
                - sh
                - -c
                - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
            initialDelaySeconds: 30
            periodSeconds: 20

          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data

  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: gp3
        resources:
          requests:
            storage: 20Gi
Enter fullscreen mode Exit fullscreen mode

This sample uses the existing gp3 StorageClass name from the environment. If you create the alternative postgres-gp3 class above, change storageClassName accordingly.

Check the PostgreSQL image version and data directory before using this manifest. PostgreSQL image upgrades need a planned database migration strategy; changing an image tag is not always a safe database upgrade.

For a real production deployment, also consider backup and restore, database upgrades, availability, monitoring, credentials rotation, and disaster recovery. A single database replica on one EBS volume is useful for demonstrating persistence, but it is not a highly available database architecture.

17.3 Create an internal PostgreSQL Service

The backend needs a stable hostname through which it can reach PostgreSQL.

apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: platform
spec:
  type: ClusterIP

  selector:
    app: postgres

  ports:
    - name: postgres
      port: 5432
      targetPort: 5432
Enter fullscreen mode Exit fullscreen mode

Apply the database resources:

kubectl apply -f postgres-service.yaml
kubectl apply -f postgres-statefulset.yaml
Enter fullscreen mode Exit fullscreen mode

Verify the StatefulSet and PVC:

kubectl get statefulsets -n platform
kubectl get pods -n platform
kubectl get pvc -n platform
kubectl get pv
kubectl get storageclass
Enter fullscreen mode Exit fullscreen mode

The PVC should eventually reach Bound, and the PostgreSQL Pod should become ready.

If the PVC remains Pending, inspect its events:

kubectl describe pvc -n platform <claim-name>
Enter fullscreen mode Exit fullscreen mode

Also inspect the EBS CSI driver and the StorageClass. Storage failures may be caused by missing IAM permissions, unsupported topology, invalid StorageClass parameters, or an unhealthy CSI controller.


Phase 8: Deploying the backend API

18. Backend Deployment

A Deployment manages replicas of the backend application and replaces Pods when they fail.

The backend image used by the capstone was:

platform-capstone-prod/backend:v1
Enter fullscreen mode Exit fullscreen mode

The backend listened on port 5000.

A representative Deployment is:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: platform
spec:
  replicas: 2

  selector:
    matchLabels:
      app: backend

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

  template:
    metadata:
      labels:
        app: backend

    spec:
      containers:
        - name: backend
          image: ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/platform-capstone-prod/backend:v1

          ports:
            - containerPort: 5000

          env:
            - name: DB_HOST
              value: postgres

            - name: DB_PORT
              value: "5432"

            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_DB

            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_USER

            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_PASSWORD

          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

          startupProbe:
            httpGet:
              path: /health/live
              port: 5000
            failureThreshold: 30
            periodSeconds: 5

          livenessProbe:
            httpGet:
              path: /health/live
              port: 5000
            periodSeconds: 10

          readinessProbe:
            httpGet:
              path: /health/ready
              port: 5000
            periodSeconds: 5

          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
Enter fullscreen mode Exit fullscreen mode

Replace ACCOUNT_ID with the actual account ID when creating the manifest. A deployment pipeline can generate the image reference automatically.

The security context assumes the image supports non-root execution. The image must be tested with the required UID, filesystem permissions, and any directories the application needs to write to.

18.1 Why probes matter

The three probes serve different purposes:

  • Startup probe: gives the application time to initialize before liveness checks take effect.
  • Liveness probe: detects an application process that is no longer functioning correctly.
  • Readiness probe: determines whether the Pod should receive traffic.

Readiness is particularly important when the backend depends on PostgreSQL. If the backend cannot access the database, it should not report itself as ready to handle database-dependent requests.

18.2 Create the backend Service

apiVersion: v1
kind: Service
metadata:
  name: backend
  namespace: platform
spec:
  type: ClusterIP

  selector:
    app: backend

  ports:
    - name: http
      port: 80
      targetPort: 5000
Enter fullscreen mode Exit fullscreen mode

The Service exposes port 80 inside the cluster and forwards traffic to port 5000 on the backend Pods.

Apply the manifests:

kubectl apply -f backend-deployment.yaml
kubectl apply -f backend-service.yaml
Enter fullscreen mode Exit fullscreen mode

Verify:

kubectl get deployment backend -n platform
kubectl get pods -n platform -l app=backend
kubectl get service backend -n platform
kubectl rollout status deployment/backend -n platform
Enter fullscreen mode Exit fullscreen mode

Test the API from inside the cluster:

kubectl run backend-test \
  --namespace platform \
  --rm -it \
  --restart=Never \
  --image=curlimages/curl \
  -- curl -i http://backend/health/live
Enter fullscreen mode Exit fullscreen mode

The temporary Pod requires the ability to pull its image. If your cluster cannot reach the image registry, use an approved diagnostic image available in your environment.


[Screenshot: The backend Deployment with two ready replicas, backend Service and port mapping and the successful backend health-check response.]


Phase 9: Deploying the frontend

19. Frontend Deployment

The frontend was served through Nginx on port 8080. Its image was:

platform-capstone-prod/frontend:v2
Enter fullscreen mode Exit fullscreen mode

The frontend Deployment used two replicas.

A representative manifest is:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: platform
spec:
  replicas: 2

  selector:
    matchLabels:
      app: frontend

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

  template:
    metadata:
      labels:
        app: frontend

    spec:
      containers:
        - name: frontend
          image: ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/platform-capstone-prod/frontend:v2

          ports:
            - containerPort: 8080

          resources:
            requests:
              cpu: "100m"
              memory: "64Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"

          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10

          livenessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 20

          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
Enter fullscreen mode Exit fullscreen mode

This sample assumes Nginx can serve the application using the selected non-root configuration. Validate the actual container image and configuration before applying it.

20. Create the frontend Service

apiVersion: v1
kind: Service
metadata:
  name: frontend
  namespace: platform
spec:
  type: ClusterIP

  selector:
    app: frontend

  ports:
    - name: http
      port: 80
      targetPort: 8080
Enter fullscreen mode Exit fullscreen mode

The frontend Service provides a stable endpoint for the Pods. The Load Balancer Controller can then use this Service as the target for traffic configured through Ingress.

Apply and validate:

kubectl apply -f frontend-deployment.yaml
kubectl apply -f frontend-service.yaml

kubectl get deployment frontend -n platform
kubectl get pods -n platform -l app=frontend
kubectl get service frontend -n platform
kubectl rollout status deployment/frontend -n platform
Enter fullscreen mode Exit fullscreen mode

Test the frontend from inside the cluster:

kubectl run frontend-test \
  --namespace platform \
  --rm -it \
  --restart=Never \
  --image=curlimages/curl \
  -- curl -I http://frontend/
Enter fullscreen mode Exit fullscreen mode

[Screenshot: The frontend Deployment showing two ready replicas and the frontend Service with port 80 targeting container port 8080.]


Phase 10: Installing the AWS Load Balancer Controller

21. Why the controller is required

Kubernetes Ingress defines how HTTP traffic should be routed, but an Ingress resource does not create an AWS Application Load Balancer by itself.

The AWS Load Balancer Controller watches Kubernetes resources and provisions or manages AWS load balancers based on their configuration.

For this project, the controller was installed using Helm. The chart version was 1.14.0, with controller version v2.14.0.

The controller also needs permission to manage the relevant AWS resources. The capstone used EKS Pod Identity for the controller's AWS identity configuration.

The exact IAM policy, association, and Helm values must match the deployed cluster and controller version.

21.1 Verify Pod Identity support

Check that the EKS Pod Identity Agent add-on is installed:

aws eks list-addons \
  --cluster-name platform-capstone-prod \
  --region eu-west-1
Enter fullscreen mode Exit fullscreen mode

Check the controller's service account and workload:

kubectl get serviceaccounts -n kube-system
kubectl get pods -n kube-system
Enter fullscreen mode Exit fullscreen mode

21.2 Install the controller with Helm

For a new environment, first follow the AWS Load Balancer Controller documentation for creating the IAM policy, role, and Pod Identity association. The policy should be appropriate for the exact controller release.

A representative Helm installation is:

helm repo add eks https://aws.github.io/eks-charts
helm repo update
Enter fullscreen mode Exit fullscreen mode

Then install the chart with the correct cluster name, region, VPC ID, service account, and controller image settings:

helm upgrade --install aws-load-balancer-controller \
  eks/aws-load-balancer-controller \
  --namespace kube-system \
  --version 1.14.0 \
  --set clusterName=platform-capstone-prod \
  --set region=eu-west-1 \
  --set vpcId=YOUR_VPC_ID
Enter fullscreen mode Exit fullscreen mode

This command is illustrative. Before using it, ensure the service account is associated with the correct Pod Identity role and that the Helm values match your IAM setup. A controller without sufficient AWS permissions may run but fail to create or reconcile load balancers.

Verify the deployment:

kubectl rollout status deployment/aws-load-balancer-controller \
  -n kube-system

kubectl logs deployment/aws-load-balancer-controller \
  -n kube-system --tail=100
Enter fullscreen mode Exit fullscreen mode

Phase 11: Exposing the frontend with Ingress

22. Create the Ingress resource

The capstone's public ALB routed the root path / to the frontend Service over HTTP port 80.

A representative Ingress manifest is:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: platform-ingress
  namespace: platform
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'

spec:
  ingressClassName: alb

  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend
                port:
                  number: 80
Enter fullscreen mode Exit fullscreen mode

This example creates an internet-facing HTTP listener. It does not configure HTTPS or a TLS certificate. For production, use HTTPS, a suitable certificate in AWS Certificate Manager, and a planned HTTP-to-HTTPS redirect.

The target type ip allows the load balancer to target Pod IPs directly through the controller's target group configuration.

Apply the manifest:

kubectl apply -f ingress.yaml
Enter fullscreen mode Exit fullscreen mode

Check its status:

kubectl get ingress -n platform
kubectl describe ingress platform-ingress -n platform
Enter fullscreen mode Exit fullscreen mode

The ALB DNS name should appear in the Ingress address after the controller successfully reconciles the resource.

22.1 Test the public frontend

Obtain the hostname:

ALB_DNS=$(kubectl get ingress platform-ingress \
  -n platform \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')

echo "$ALB_DNS"
Enter fullscreen mode Exit fullscreen mode

Test the HTTP endpoint:

curl -I "http://${ALB_DNS}/"
Enter fullscreen mode Exit fullscreen mode

Use http:// when the Ingress only defines an HTTP listener. Attempting HTTPS without an HTTPS listener and certificate will not test the configuration correctly.

22.2 Troubleshooting a public ALB

If the application does not load, check the problem layer by layer.

First, inspect Ingress events:

kubectl describe ingress platform-ingress -n platform
Enter fullscreen mode Exit fullscreen mode

Then inspect the controller logs:

kubectl logs deployment/aws-load-balancer-controller \
  -n kube-system --tail=200
Enter fullscreen mode Exit fullscreen mode

Check the frontend Pods and Service:

kubectl get pods -n platform -l app=frontend
kubectl get service frontend -n platform
kubectl get endpoints frontend -n platform
Enter fullscreen mode Exit fullscreen mode

Finally, inspect the ALB target groups and health status in the AWS Console or through the ELBv2 API.

A healthy ALB DNS record does not guarantee that the target group is healthy. Common causes include security group restrictions, incorrect health-check paths, a Service targeting the wrong port, or Pods that are not ready.


Phase 12: Resource management and autoscaling

23. Why resource requests and limits matter

Kubernetes uses resource requests and limits to help schedule workloads and control their resource consumption.

  • Requests tell the scheduler the amount of CPU and memory the container needs for scheduling purposes.
  • Limits cap resource consumption. CPU limits can throttle a process, while exceeding a memory limit can cause the container to be terminated.

Requests and limits should reflect observed application behavior, not arbitrary values. The examples in this document are starting points, not universal production recommendations.

Check resource configuration in the live manifests:

kubectl get deployments -n platform -o yaml
kubectl describe pods -n platform
Enter fullscreen mode Exit fullscreen mode

The capstone assessment required workloads to have resource controls and health probes. Review every workload, including database workloads, to verify that the final manifests satisfy the requirement.

24. Configure Horizontal Pod Autoscaling

The Horizontal Pod Autoscaler (HPA) adjusts the number of replicas in response to observed resource utilization.

The capstone configured CPU-based scaling with a target of 70%, a minimum of two replicas, and a maximum of five replicas.

A representative HPA manifest for the backend is:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: backend-hpa
  namespace: platform

spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: backend

  minReplicas: 2
  maxReplicas: 5

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
Enter fullscreen mode Exit fullscreen mode

For CPU utilization-based scaling, resource requests must be configured. If requests are missing, the HPA may be unable to calculate the intended utilization percentage.

The Metrics Server must also be healthy.

Verify metrics:

kubectl top nodes
kubectl top pods -n platform
Enter fullscreen mode Exit fullscreen mode

Check the HPA:

kubectl get hpa -n platform
kubectl describe hpa backend-hpa -n platform
Enter fullscreen mode Exit fullscreen mode

The HPA output includes current and target utilization, minimum and maximum replicas, and the current replica count.

24.1 Test autoscaling

A load test can help demonstrate whether the HPA reacts to demand. Use a controlled test environment and stop the test after collecting the evidence.

For example, a temporary load generator can send repeated requests to the backend Service. The exact command depends on the tools available in the cluster and the application's endpoints.

While the test is running, observe:

kubectl get hpa -n platform --watch
Enter fullscreen mode Exit fullscreen mode

In another terminal:

kubectl get pods -n platform -l app=backend --watch
Enter fullscreen mode Exit fullscreen mode

After the test, confirm that the replica count stabilizes and eventually scales down when utilization falls. Scaling is not necessarily immediate because Kubernetes uses sampling intervals, stabilization behavior, and scheduling time.

[Screenshot: Insert kubectl top pods -n platform showing resource metrics.]

[Screenshot: Insert kubectl get hpa -n platform showing the target CPU utilization.]

&

[Screenshot: The scaling test showing replicas increasing.]

[Screenshot: The final HPA status after the test.]


Phase 13: Validating PostgreSQL persistence and recovery

25. Why Pod recovery is not the same as data recovery

Kubernetes can recreate a Pod, but whether its data survives depends on where the data is stored.

Data written only to a container's writable layer is not a reliable persistence mechanism. When a container or Pod is replaced, that local state can disappear.

In this capstone, PostgreSQL stored its data on an EBS-backed persistent volume. The PVC provided the durable storage request, and the StatefulSet associated that claim with the PostgreSQL workload.

The validation goal was to demonstrate that the database data remained available after the PostgreSQL Pod was deleted and recreated.

25.1 Insert a test record

Connect to PostgreSQL using the database credentials and tooling configured for the environment. One approach is to use kubectl exec with psql:

kubectl exec -it postgres-0 -n platform -- \
  psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
Enter fullscreen mode Exit fullscreen mode

The environment variables in your local shell will not automatically be available inside the Pod. For this reason, substitute the correct database name and user from the configured Secret, or use a secure method to pass them. Do not put the database password in a public screenshot.

Create a test table and insert a row:

CREATE TABLE IF NOT EXISTS persistence_test (
    id SERIAL PRIMARY KEY,
    message TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

INSERT INTO persistence_test (message)
VALUES ('Capstone persistence validation');

SELECT * FROM persistence_test;
Enter fullscreen mode Exit fullscreen mode

Record the returned row for comparison.

25.2 Delete the PostgreSQL Pod

Delete the Pod without deleting the StatefulSet or PVC:

kubectl delete pod postgres-0 -n platform
Enter fullscreen mode Exit fullscreen mode

Wait for Kubernetes to recreate it:

kubectl get pods -n platform --watch
Enter fullscreen mode Exit fullscreen mode

Once it is ready, query the test record again:

kubectl exec -it postgres-0 -n platform -- \
  psql -U YOUR_DB_USER -d YOUR_DB_NAME \
  -c "SELECT * FROM persistence_test;"
Enter fullscreen mode Exit fullscreen mode

Replace the example database values with the actual values from the configured Secret.

The test succeeds when the Pod has been recreated and the previously inserted row is still present.

25.3 Verify the PVC remains bound

kubectl get pvc -n platform
kubectl get pv
Enter fullscreen mode Exit fullscreen mode

The PVC should remain bound to the volume associated with PostgreSQL.

This test demonstrates persistence across Pod recreation. It does not, by itself, prove that backups can be restored, that a volume survives every infrastructure failure, or that the database is highly available. Those require separate tests.


Phase 14: Application and platform validation

26. Validate the cluster and workloads

Validation should cover both the underlying infrastructure and the application behavior.

Start by checking the nodes:

kubectl get nodes -o wide
Enter fullscreen mode Exit fullscreen mode

Then inspect all resources in the application namespace:

kubectl get all -n platform
Enter fullscreen mode Exit fullscreen mode

Check the database storage:

kubectl get pvc -n platform
kubectl get pv
Enter fullscreen mode Exit fullscreen mode

Check autoscaling and metrics:

kubectl get hpa -n platform
kubectl top pods -n platform
Enter fullscreen mode Exit fullscreen mode

Inspect recent events if something is not healthy:

kubectl get events -n platform \
  --sort-by=.metadata.creationTimestamp
Enter fullscreen mode Exit fullscreen mode

The desired state was two frontend replicas, two backend replicas under normal conditions, and one PostgreSQL replica with a bound PVC. HPA could adjust the backend replica count between two and five in response to CPU utilization.

The actual replica count at any particular time depends on scaling activity and workload conditions.

27. Validate the frontend and backend

For the public frontend:

curl -I "http://${ALB_DNS}/"
Enter fullscreen mode Exit fullscreen mode

For the backend, test its health endpoints through the internal Service or an approved diagnostic Pod:

kubectl run backend-test \
  --namespace platform \
  --rm -it \
  --restart=Never \
  --image=curlimages/curl \
  -- curl -i http://backend/health/live
Enter fullscreen mode Exit fullscreen mode

Then test readiness:

kubectl run backend-ready-test \
  --namespace platform \
  --rm -it \
  --restart=Never \
  --image=curlimages/curl \
  -- curl -i http://backend/health/ready
Enter fullscreen mode Exit fullscreen mode

A 200 response is useful evidence, but readiness is meaningful only if the endpoint checks the dependencies required by the application.

28. Validate a rolling update

A Deployment can replace application Pods gradually when its image or Pod template changes.

For a controlled test, publish a new immutable image tag and update the Deployment to use it. Avoid changing a production tag to point to a different image without a controlled release process.

For example:

kubectl set image deployment/backend \
  backend=YOUR_BACKEND_ECR_URI:v2 \
  -n platform
Enter fullscreen mode Exit fullscreen mode

Monitor the rollout:

kubectl rollout status deployment/backend -n platform
kubectl rollout history deployment/backend -n platform
Enter fullscreen mode Exit fullscreen mode

If the update fails, inspect the rollout and Pod events before considering a rollback:

kubectl describe deployment backend -n platform
kubectl get pods -n platform -l app=backend
kubectl rollout undo deployment/backend -n platform
Enter fullscreen mode Exit fullscreen mode

The example uses v2 as a placeholder. The tag must exist in ECR before applying it.

A rolling-update strategy with maxUnavailable: 0 and maxSurge: 1 aims to keep the desired number of available replicas during a rollout, provided there is sufficient capacity and the readiness probes accurately identify healthy Pods. It does not guarantee zero downtime under every failure condition.


Phase 15: Terraform validation and infrastructure documentation

29. Validate Terraform before final delivery

Infrastructure validation is an important part of the delivery process.

From the production Terraform directory:

cd ~/platform-capstone/terraform/environments/prod

terraform fmt -check -recursive
terraform validate
terraform plan
Enter fullscreen mode Exit fullscreen mode

After a successful deployment, a plan that reports no changes is useful evidence that the configuration matches the infrastructure Terraform is tracking at that moment.

However, a clean plan does not prove the application is healthy. Runtime checks remain necessary.

If you have already destroyed the infrastructure, do not run terraform apply simply to make the plan return to zero changes. A destroy plan or a plan proposing resource creation is expected when the infrastructure no longer exists. Review the state and the intended lifecycle before proceeding.

30. Generate a Terraform dependency graph

Terraform can generate a graph showing relationships between resources.

Run this from the directory containing the environment's Terraform root configuration:

cd ~/platform-capstone/terraform/environments/prod

terraform init
terraform graph | dot -Tsvg \
  > ~/platform-capstone/infrastructure.svg
Enter fullscreen mode Exit fullscreen mode

Open the SVG in Windows from WSL:

explorer.exe "$(wslpath -w ~/platform-capstone/infrastructure.svg)"
Enter fullscreen mode Exit fullscreen mode

The graph is a dependency graph, not a complete application architecture diagram. For the blog post, a separate diagram showing users, ALB, Kubernetes Services, Pods, PostgreSQL, and EBS storage is more useful for readers who are new to the project.

If the infrastructure has been destroyed, Terraform graph generation can still be useful when the configuration and provider setup remain available locally, but it does not prove that AWS resources are currently running.

[Screenshot: The Terraform validation output.]


Phase 16: GitHub and project delivery

31. Version control and repository hygiene

Git provides a history of infrastructure and application changes. GitHub makes the project available for review and future reuse.

The capstone reached a final commit:

76c49b1 feat: deploy AWS Kubernetes platform capstone
Enter fullscreen mode Exit fullscreen mode

Before publishing, confirm that the hash still matches your repository and that the latest changes are committed.

Useful commands include:

git status
git log --oneline -5
git diff --stat
Enter fullscreen mode Exit fullscreen mode

To check whether the current branch is ahead of or behind its upstream:

git status -sb
Enter fullscreen mode Exit fullscreen mode

Do not publish Terraform state files, cloud credentials, production database passwords, or other sensitive configuration. A secret file being ignored today does not prove that it was never committed in an earlier commit.

If a secret was committed, remove it from the repository history as appropriate and rotate the exposed credential. Removing the file from the latest commit alone does not make an exposed secret safe.

[Screenshot: The GitHub repository overview.]

[Screenshot: The final commit and clean Git status.]


Phase 17: Infrastructure cleanup

32. Why teardown matters

AWS resources can continue to incur charges even after an application is no longer being tested. For example, NAT Gateways, worker nodes, load balancers, EBS volumes, and container images can all contribute to cost.

The capstone was therefore torn down after the validation evidence had been collected.

The cleanup process also needed to preserve the remote Terraform state in S3 so that the deployment record remained available.

32.1 Remove the Ingress first

Deleting the Ingress gives the AWS Load Balancer Controller an opportunity to remove the ALB and its associated resources.

kubectl delete ingress --all -n platform
Enter fullscreen mode Exit fullscreen mode

Wait for the ALB to disappear from AWS before proceeding, allowing time for the controller to reconcile the deletion.

32.2 Delete the application namespace

kubectl delete namespace platform
Enter fullscreen mode Exit fullscreen mode

This removes namespaced application resources, including Deployments, Services, StatefulSets, and PVCs, subject to Kubernetes finalizers and storage lifecycle policies.

Check the namespace:

kubectl get namespaces
Enter fullscreen mode Exit fullscreen mode

Warning: deleting a namespace can remove the PVC that holds database data. Ensure that the data is no longer needed and that any required backup or evidence has been preserved before doing this.

32.3 Review Terraform's destroy plan

From the production Terraform root:

cd ~/platform-capstone/terraform/environments/prod

terraform plan -destroy
Enter fullscreen mode Exit fullscreen mode

Inspect the planned actions. Ensure that the plan does not include deleting the remote S3 state bucket. The S3 backend bucket was managed separately from the production application infrastructure.

If the plan is correct, proceed with the destroy operation:

terraform destroy
Enter fullscreen mode Exit fullscreen mode

Review the confirmation prompt carefully before approving.

Do not destroy the bootstrap configuration if it manages the S3 bucket that stores the remote state.

32.4 Verify the cleanup

The audit used AWS CLI commands to check for remaining EKS clusters, EC2 instances, load balancers, NAT Gateways, Elastic IPs, EBS volumes, snapshots, ECR repositories, and other related resources.

For example:

aws eks list-clusters --region eu-west-1

aws ec2 describe-instances \
  --region eu-west-1 \
  --filters Name=instance-state-name,Values=pending,running,stopping,stopped

aws elbv2 describe-load-balancers --region eu-west-1

aws ec2 describe-nat-gateways \
  --region eu-west-1 \
  --filter Name=state,Values=pending,available,deleting,failed

aws ec2 describe-addresses --region eu-west-1

aws ec2 describe-volumes --region eu-west-1

aws ecr describe-repositories --region eu-west-1
Enter fullscreen mode Exit fullscreen mode

Empty results in these commands indicate that the corresponding queries found no matching resources.

In the capstone cleanup audit, the main infrastructure was absent from the checked regions. The two EC2 worker instances appeared as terminated, while the checks found no EKS clusters, load balancers, NAT Gateways, Elastic IPs, EBS volumes, snapshots, or ECR repositories in the primary region.

The audit also found no matching capstone IAM roles and no remaining non-default security groups or network interfaces in the primary region.

The Terraform state remained in the S3 bucket:

s3://platform-capstone-terraform-state-911xxxx3075/platform-capstone/prod/terraform.tfstate
Enter fullscreen mode Exit fullscreen mode

The bucket was intentionally preserved. S3 is not automatically free, so review storage, requests, versioned objects, and billing periodically.

These checks provide strong evidence that the core capstone infrastructure was removed, but they do not prove that every AWS service in every region is empty. Review any remaining resources before assuming there are no further charges.


33. Results and outcomes

The capstone brought the infrastructure, application deployment, and validation stages together into one practical platform engineering exercise.

The main outcomes were:

  1. Infrastructure as Code: AWS infrastructure was defined and provisioned through Terraform modules and a production environment configuration.
  2. Network separation: the VPC included public and private subnets across two Availability Zones.
  3. Managed Kubernetes: the Amazon EKS cluster ran two managed t3.medium worker nodes.
  4. Container delivery: separate frontend and backend images were built and published to Amazon ECR.
  5. Application orchestration: Kubernetes Deployments and Services managed the frontend and backend workloads.
  6. Persistent storage: PostgreSQL used a StatefulSet and a bound PVC backed by encrypted gp3 EBS storage.
  7. External access: an AWS Load Balancer Controller reconciled the Ingress configuration into an internet-facing ALB.
  8. Health validation: frontend and backend health checks were used to validate application readiness.
  9. Autoscaling: CPU-based HPA configuration used a 70% target and a replica range of two to five.
  10. Recovery testing: a PostgreSQL Pod was deleted and recreated, and the test record remained available on persistent storage.
  11. Delivery documentation: the project was committed to GitHub and documented for future reference.
  12. Resource cleanup: the primary AWS infrastructure was removed after testing, while the S3 state bucket was preserved.

These outcomes demonstrate how infrastructure provisioning, containerization, Kubernetes orchestration, persistence, networking, and validation combine into a single cloud platform.

They also highlight an important engineering lesson: a resource being present is not the same as it being healthy. A running Pod does not prove the application can reach its database. A created Ingress does not prove the ALB targets are healthy. A bound PVC does not prove that a recovery process works. Each layer needs its own test.


34. Lessons learned

Infrastructure should be reproducible

Terraform makes infrastructure easier to review and recreate, but only when the configuration, variables, provider versions, and remote state are maintained carefully.

Application health must include dependencies

A backend can return a successful liveness response while its database is unavailable. Liveness and readiness should represent different conditions, and readiness should verify the dependencies required to serve requests.

Persistent storage requires lifecycle awareness

Stateful workloads need more than a PVC declaration. Storage classes, volume binding, access modes, encryption, reclaim policies, backup, and restoration all influence the safety and lifecycle of database data.

Autoscaling depends on observability and resource requests

An HPA manifest is only one part of autoscaling. The Metrics Server must be working, CPU requests must be set, and the cluster needs sufficient capacity to schedule additional replicas.

Networking should be tested layer by layer

When an application is not reachable, check the listener protocol, Ingress events, controller logs, Service endpoints, target health, and security group rules. This is more effective than repeatedly changing configuration without identifying the failing layer.

Cleanup is part of engineering

A project is not complete merely because it works. Reviewing the resources created, preserving the necessary state, removing billable infrastructure, and documenting the final status are part of responsible cloud operations.


35. Future improvements

There are several areas in which this capstone could be extended.

Security

  • Add HTTPS using AWS Certificate Manager.
  • Redirect HTTP to HTTPS.
  • Store production secrets in AWS Secrets Manager or another managed secret system.
  • Review least-privilege IAM permissions and security group rules.
  • Add image vulnerability scanning and container policy checks.
  • Consider Kubernetes NetworkPolicies where the networking implementation supports them.

Reliability

  • Add automated PostgreSQL backups and restore testing.
  • Document database upgrade and recovery procedures.
  • Evaluate a managed database such as Amazon RDS for a production architecture.
  • Add disruption budgets and scheduling constraints where appropriate.
  • Test rolling updates under realistic traffic conditions.

Observability

  • Centralize application and controller logs.
  • Add application-level metrics and dashboards.
  • Define service-level indicators for availability and latency.
  • Alert on unhealthy Pods, failed rollouts, and storage issues.

Delivery automation

  • Add a GitHub Actions pipeline to run Terraform formatting and validation.
  • Build and scan Docker images on pull requests.
  • Push versioned images to ECR after approved builds.
  • Deploy through a controlled release workflow.
  • Keep credentials out of CI logs and repository files.

Cost management

  • Review NAT Gateway architecture and data transfer.
  • Evaluate the worker node size against observed workload requirements.
  • Set AWS Budgets and billing alerts.
  • Define a documented cleanup procedure for non-production environments.
  • Periodically review ECR images, S3 versions, and retained EBS volumes.

These improvements would move the project closer to a more complete platform engineering implementation.


36. Final checklist

Use this checklist when reviewing the project or preparing the next version.

AWS infrastructure

  • [x] EKS cluster provisioned
  • [x] VPC and networking configured
  • [x] Worker nodes provisioned
  • [x] ECR repositories created
  • [x] EBS volume provisioned
  • [x] Application Load Balancer created

Kubernetes

  • [x] Worker nodes reached Ready
  • [x] Workloads and Services deployed
  • [x] Resource metrics checked
  • [x] HPA configured and tested
  • [x] PostgreSQL PVC bound
  • [x] Ingress configured

Validation

  • [x] Public frontend tested
  • [x] Backend readiness tested
  • [x] Database persistence and recovery tested
  • [x] HPA scaling test completed

Delivery

  • [x] Terraform validation performed
  • [x] GitHub repository maintained
  • [x] Final commit recorded
  • [x] Clean Git status checked

Cleanup

  • [x] Main capstone infrastructure removed
  • [x] Primary-region resource audit performed
  • [x] Additional regional checks performed
  • [x] Terraform state preserved in S3

The checked boxes above record the project milestones reported during the capstone. They should be retained as evidence of completed work, while future improvements should be tracked separately.


Conclusion

This capstone was an opportunity to work through the lifecycle of a cloud-native application platform: defining infrastructure, building images, provisioning Kubernetes, deploying services, persisting data, exposing the application, testing scaling and recovery, and cleaning up resources responsibly.

Repository: [https://github.com/Jahmeeu-Cloud/platform-capstone]

The biggest takeaway was the importance of understanding the boundaries between the different layers. Terraform manages infrastructure; Docker packages the application; ECR stores the images; Kubernetes manages workloads; the AWS Load Balancer Controller connects Kubernetes networking resources to AWS load balancers; and persistent storage allows the database to retain data beyond the lifetime of a Pod.

Each component serves a different purpose, and the platform only works as expected when those components are configured and validated together.

This is a foundation that can be extended into more advanced work involving CI/CD, GitOps, managed secrets, observability, policy enforcement, and platform self-service.

The aim is not simply to deploy an application once. It is to build an environment that another engineer can understand, reproduce, test, and improve.

Project status: The core capstone deployment and validation milestones were completed, and the main AWS infrastructure was subsequently removed to avoid unnecessary ongoing resource charges. The Terraform state was retained in Amazon S3 for future reference.

Top comments (0)