1. What you will build
Customer / Browser
View properties, submit inquiries
AWS Application Load Balancer
Kubernetes Ingress managed by AWS Load Balancer Controller
Frontend Deployment
Nginx + React
frontend-service
Backend Deployment
FastAPI + SQL
backend-service
Amazon RDS PostgreSQL
Main application database — outside Kubernetes
Separate PostgreSQL StatefulSet
PVC → PV → AWS EBS volume
Persistence experiment only
Fluent Bit DaemonSet
Collects node container logs → CloudWatch Logs
What each component is responsible for
| Component | Responsibility |
|---|---|
| Frontend Deployment | Runs website Pods and maintains desired replicas |
| Backend Deployment | Runs API Pods that process property and inquiry requests |
| ReplicaSet | Maintains Pods created by a Deployment |
| ClusterIP Service | Provides a stable internal network endpoint |
| Ingress / ALB | Routes incoming HTTP traffic to Services |
| Amazon RDS | Manages PostgreSQL database infrastructure |
| StatefulSet | Maintains stable Pod identities for our separate database experiment |
| PVC / PV | Connects the StatefulSet to persistent storage |
| StorageClass | Defines how storage is dynamically provisioned |
| EBS CSI Driver | Integrates Kubernetes storage operations with AWS EBS |
| DaemonSet | Ensures a log collection agent runs on eligible nodes |
| kubelet | Starts and monitors Pod containers on a worker node |
| IAM | Controls AWS permissions for controllers and workloads |
| Kubernetes Secret | Provides application credentials to Pods |
Important: We will not place RDS inside a StatefulSet. These are two separate database deployment models.
2. Lab phases
| Phase | Activity |
|---|---|
| 1 | Inspect existing repository and EKS cluster |
| 2 | Verify frontend/backend application and Docker images |
| 3 | Deploy frontend/backend using Deployments |
| 4 | Create internal ClusterIP Services |
| 5 | Configure ALB Ingress |
| 6 | Provision and connect Amazon RDS PostgreSQL |
| 7 | Deploy separate PostgreSQL StatefulSet |
| 8 | Configure EBS CSI, StorageClass and PVC |
| 9 | Deploy Fluent Bit DaemonSet with CloudWatch |
| 10 | Test, troubleshoot, document and clean up |
This is a multi-session project, not a 30-minute exercise. Complete one phase successfully before proceeding.
3. Phase 1 — Inspect the existing project
Why: A DevOps Engineer should understand the existing infrastructure and application before modifying it.
On the EC2 instance:
cd ~/real-estate-company
pwd
git status --short
git remote -v
ls -la
ls -la backend
ls -la frontend
ls -la k8s
find backend frontend k8s \
-maxdepth 2 -type f | sort | head -100
Inspect Docker and Kubernetes files:
find . -name Dockerfile -o -name '*.yaml' -o -name '*.yml'
Check the AWS identity and cluster:
aws sts get-caller-identity
aws configure get region
kubectl config current-context
kubectl get nodes -o wide
kubectl get namespaces
kubectl get deployments,services,ingresses -A
Student checkpoint: Identify the frontend technology, backend technology, existing container ports, current Kubernetes namespace, ECR repositories and EKS cluster name.
Do not print .env files, passwords, tokens or database connection strings into shared classroom screenshots.
4. Phase 2 — Prepare the application
What the application must do
Your Real Estate Company will have two parts.
Frontend: Display available properties, prices, locations and an inquiry form.
Backend: Return property listings, receive customer inquiries, validate requests and save them in PostgreSQL.
The backend is responsible for SQL operations. Kubernetes is responsible for running and connecting the application, not for implementing its business logic.
4.1 Reference backend — FastAPI
If your existing backend already implements these features, retain it and adapt the Kubernetes configuration. Otherwise, create this reference backend in a separate directory so that you do not overwrite your classmates' work.
cd ~/real-estate-company
mkdir -p lab-reference/backend
cd lab-reference/backend
Create requirements.txt:
fastapi==0.115.12
uvicorn==0.34.2
psycopg[binary]==3.2.6
Create main.py:
import osfrom contextlib import asynccontextmanagerimport psycopgfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, FieldDATABASE_URL = os.environ["DATABASE_URL"]def connect(): return psycopg.connect(DATABASE_URL, connect_timeout=5)@asynccontextmanagerasync def lifespan(app: FastAPI): with connect() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS properties ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title TEXT NOT NULL, city TEXT NOT NULL, price INTEGER NOT NULL ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS inquiries ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, property_id INTEGER NOT NULL REFERENCES properties(id), customer_name TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """) count = conn.execute(
This is teaching code, not a production-ready customer-data platform. It omits authentication, authorization, rate limiting, full email validation and a proper migration workflow. Use fictitious customer information only.
Create Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --uid 10001 --create-home appuser
USER 10001
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Why Docker? Kubernetes deploys container images. It does not directly deploy your Python source folder.
4.2 Reference frontend — React
For students who already have a working React frontend, retain the existing code and adapt its API calls to /api/properties and /api/inquiries.
For a new reference frontend:
cd ~/real-estate-company/lab-reference
npm create vite@latest frontend -- --template react
cd frontend
npm install
Replace src/App.jsx:
import { useEffect, useState } from "react";
export default function App() {
const [properties, setProperties] = useState([]);
const [selected, setSelected] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [result, setResult] = useState("");
useEffect(() => {
fetch("/api/properties")
.then(async r => {
if (!r.ok) throw new Error("API unavailable");
return r.json();
})
.then(data => {
setProperties(data);
if (data.length) setSelected(String(data[0].id));
})
.catch(() => setResult("Unable to load properties"));
}, []);
async function submit(e) {
e.preventDefault();
try {
const response = await fetch("/api/inquiries", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
property_id: Number(selected),
customer_name: name,
email,
message
})
});
if (!response.ok) throw new Error("Request failed");
const data = await response.json();
setResult(`Inquiry saved! Reference #${data.id}`);
setMessage("");
} catch {
setResult("Unable to save inquiry");
}
}
return (
<main style={{maxWidth: 900, margin: "40px auto", fontFamily: "Arial"}}>
<h1>JumpToTech Real Estate</h1>
<p>Explore properties and request more information.</p>
<section style={{display: "grid", gap: 16}}>
{properties.map(p => (
<article key={p.id} style={{border: "1px solid #ddd", padding: 16}}>
<h2>{p.title}</h2>
<p>{p.city}</p>
<strong>${p.price.toLocaleString()}</strong>
</article>
))}
</section>
<h2>Contact an agent</h2>
<form onSubmit={submit} style={{display: "grid", gap: 12}}>
<select value={selected} onChange={e => setSelected(e.target.value)}>
{properties.map(p =>
<option key={p.id} value={p.id}>{p.title}</option>
)}
</select>
<input placeholder="Your name" value={name}
onChange={e => setName(e.target.value)} required />
<input type="email" placeholder="Email" value={email}
onChange={e => setEmail(e.target.value)} required />
<textarea placeholder="Your question" value={message}
onChange={e => setMessage(e.target.value)} required />
<button type="submit">Send inquiry</button>
</form>
<p role="status">{result}</p>
</main>
);
}
Create nginx.conf:
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Create 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 nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
Why use /api? The browser can access the frontend and backend through the same ALB hostname. The Ingress sends /api/* requests to the backend, avoiding the need for browser-side access to a private ClusterIP.
5. Phase 3 — Build images and push to Amazon ECR
First define your AWS environment.
export AWS_REGION=us-east-1
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity \
--query Account --output text)
export EKS_CLUSTER="YOUR_EKS_CLUSTER"
export NAMESPACE="real-estate-lab"
export FRONTEND_REPO="real-estate-frontend"
export BACKEND_REPO="real-estate-backend"
export IMAGE_TAG="lab-v1"
Replace YOUR_EKS_CLUSTER with the actual cluster name.
Create ECR repositories
aws ecr describe-repositories \
--repository-names "$FRONTEND_REPO" \
--region "$AWS_REGION" >/dev/null 2>&1 ||
aws ecr create-repository \
--repository-name "$FRONTEND_REPO" \
--region "$AWS_REGION"
Repeat for the backend:
aws ecr describe-repositories \
--repository-names "$BACKEND_REPO" \
--region "$AWS_REGION" >/dev/null 2>&1 ||
aws ecr create-repository \
--repository-name "$BACKEND_REPO" \
--region "$AWS_REGION"
Authenticate Docker:
aws ecr get-login-password --region "$AWS_REGION" |
docker login \
--username AWS \
--password-stdin \
"$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com"
Build and push from the reference implementation:
cd ~/real-estate-company/lab-reference
docker build --platform linux/amd64 \
-t "$BACKEND_REPO:$IMAGE_TAG" ./backend
docker tag "$BACKEND_REPO:$IMAGE_TAG" \
"$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$BACKEND_REPO:$IMAGE_TAG"
docker push \
"$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$BACKEND_REPO:$IMAGE_TAG"
Frontend:
docker build --platform linux/amd64 \
-t "$FRONTEND_REPO:$IMAGE_TAG" ./frontend
docker tag "$FRONTEND_REPO:$IMAGE_TAG" \
"$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$FRONTEND_REPO:$IMAGE_TAG"
docker push \
"$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$FRONTEND_REPO:$IMAGE_TAG"
These commands assume x86-64 EKS nodes. If your EKS nodes are ARM64, use linux/arm64 instead.
Verify
aws ecr list-images \
--repository-name "$BACKEND_REPO" \
--region "$AWS_REGION"
aws ecr list-images \
--repository-name "$FRONTEND_REPO" \
--region "$AWS_REGION"
Student question: Why does Kubernetes need the ECR image URI rather than the source-code directory?
Answer: The container runtime pulls a packaged image containing the application and its dependencies.
6. Phase 4 — Amazon RDS PostgreSQL
Why RDS?
In this architecture, RDS manages the database service outside the Kubernetes cluster.
Kubernetes does not schedule or restart the RDS database process. AWS manages the database instance infrastructure, while your team remains responsible for database configuration, access control, schema, queries and appropriate backup settings.
6.1 Inspect networking before provisioning
aws eks describe-cluster \
--name "$EKS_CLUSTER" \
--region "$AWS_REGION" \
--query 'cluster.resourcesVpcConfig'
Find the VPC ID and subnet IDs.
For this lab, RDS should be in private subnets reachable from the EKS worker nodes. Do not make PostgreSQL publicly accessible.
You will need:
- A DB subnet group covering suitable subnets in at least two Availability Zones.
- An RDS security group.
- A rule allowing TCP 5432 from the EKS node security group, or another narrowly scoped security group used by the workloads.
- A PostgreSQL database and database user.
- A secure way to provide the credentials to the application.
6.2 Create RDS
For a shared class, the instructor should provision RDS once. Students should not each create expensive AWS infrastructure without checking costs and quotas.
In the AWS Console:
- Open RDS → Databases → Create database.
- Select PostgreSQL.
- Choose an appropriately small lab instance, such as
db.t4g.microwhere supported and available. - Set database name to
realestate. - Use RDS-managed master credentials in AWS Secrets Manager where available.
- Choose the same VPC as EKS.
- Select a private DB subnet group.
- Set Public access to No.
- Attach the restricted RDS security group.
- Configure backups and deletion protection according to the lab's cleanup plan.
Do not store the master password in Git.
After RDS becomes available, record the endpoint:
YOUR_RDS_ENDPOINT.rds.amazonaws.com
6.3 Create an application database user
Use a database administrator connection from an approved private network location.
Create a dedicated application user with the permissions required for the lab rather than using the RDS master account in the backend.
For a simplified classroom environment, an instructor can provision the database and grant the application user ownership of its schema. Production systems should use separate migration and runtime roles with narrower privileges.
6.4 Create the Kubernetes Secret
The simplest teaching method is a Kubernetes Secret. In production, prefer AWS Secrets Manager with a supported integration such as the Secrets Store CSI Driver or External Secrets Operator.
Create the namespace:
kubectl create namespace "$NAMESPACE"
On your private terminal, read the application password without echoing it:
read -rs -p "RDS application password: " DB_PASSWORD
echo
Construct the connection string using the actual endpoint, database username and URL-encoded password. Special characters in passwords must be percent-encoded in a URI.
For this lab, a simpler and safer option is to create individual Secret keys:
kubectl create secret generic realestate-db \
-n "$NAMESPACE" \
--from-literal=DB_HOST="YOUR_RDS_ENDPOINT" \
--from-literal=DB_PORT="5432" \
--from-literal=DB_NAME="realestate" \
--from-literal=DB_USER="realestate_app" \
--from-literal=DB_PASSWORD="$DB_PASSWORD"
Then remove the shell variable:
unset DB_PASSWORD
Do not paste passwords into a shared Zoom chat or commit them to Git.
For the reference FastAPI application, we will use a small code change to construct the PostgreSQL connection from these environment variables instead of a single URL.
Replace:
DATABASE_URL = os.environ["DATABASE_URL"]def connect(): return psycopg.connect(DATABASE_URL, connect_timeout=5)
with:
def connect(): return psycopg.connect( host=os.environ["DB_HOST"], port=int(os.environ.get("DB_PORT", "5432")), dbname=os.environ["DB_NAME"], user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], connect_timeout=5, sslmode="require" )
Rebuild and push the backend image with a new tag, for example lab-v2, and use that tag in the Deployment below.
Production note: sslmode=require encrypts the connection but does not provide full server identity verification. Production should use verify-full with an appropriate RDS CA bundle.
7. Phase 5 — Kubernetes Deployments and Services
Create a directory for the new lab manifests:
cd ~/real-estate-company
mkdir -p lab-reference/k8s
cd lab-reference/k8s
7.1 Backend Deployment
Create backend.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: realestate-backend
namespace: real-estate-lab
spec:
replicas: 2
selector:
matchLabels:
app: realestate-backend
template:
metadata:
labels:
app: realestate-backend
spec:
containers:
- name: backend
image: BACKEND_IMAGE_URI
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: realestate-db
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
startupProbe:
httpGet:
path: /healthz
port: 8000
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /readyz
port: 8000
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: real-estate-lab
spec:
type: ClusterIP
selector:
app: realestate-backend
ports:
- port: 80
targetPort: 8000
Replace BACKEND_IMAGE_URI with the full ECR image URI, including its tag.
Why two replicas? We want to demonstrate how a Deployment maintains multiple backend Pods and how a Service routes requests to healthy endpoints.
Why separate readiness and liveness? If RDS is unavailable, readiness should fail so the Pod stops receiving traffic. Liveness should not depend on RDS, or a temporary database outage could cause unnecessary container restarts.
7.2 Frontend Deployment
Create frontend.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: realestate-frontend
namespace: real-estate-lab
spec:
replicas: 2
selector:
matchLabels:
app: realestate-frontend
template:
metadata:
labels:
app: realestate-frontend
spec:
containers:
- name: frontend
image: FRONTEND_IMAGE_URI
ports:
- containerPort: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /
port: 8080
livenessProbe:
httpGet:
path: /
port: 8080
---
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: real-estate-lab
spec:
type: ClusterIP
selector:
app: realestate-frontend
ports:
- port: 80
targetPort: 8080
Replace FRONTEND_IMAGE_URI with the full ECR URI.
7.3 Apply and verify
kubectl apply -f backend.yaml
kubectl apply -f frontend.yaml
kubectl rollout status deployment/realestate-backend \
-n real-estate-lab --timeout=180s
kubectl rollout status deployment/realestate-frontend \
-n real-estate-lab --timeout=180s
Check:
kubectl get deployments -n real-estate-lab
kubectl get replicasets -n real-estate-lab
kubectl get pods -n real-estate-lab -o wide
kubectl get services -n real-estate-lab
kubectl get endpointslices -n real-estate-lab
Expected result: Two frontend Pods and two backend Pods should be Ready, provided the images can be pulled and the backend can reach RDS.
If backend Pods are not Ready, investigate before moving on:
kubectl describe pod -n real-estate-lab \
-l app=realestate-backend
kubectl logs deployment/realestate-backend \
-n real-estate-lab --tail=50
7.4 Test the backend without ALB
Use port forwarding from a machine with kubectl access:
kubectl port-forward \
-n real-estate-lab svc/backend-service 8081:80
In a second terminal:
curl -i http://localhost:8081/healthz
curl -i http://localhost:8081/readyz
curl -i http://localhost:8081/api/properties
Create a sample inquiry:
curl -i -X POST http://localhost:8081/api/inquiries \
-H 'Content-Type: application/json' \
-d '{
"property_id": 1,
"customer_name": "Test Student",
"email": "student@example.com",
"message": "I would like a property tour."
}'
Verify:
curl http://localhost:8081/api/inquiries/count
Student checkpoint: Explain the complete path from the HTTP request through Service, Backend Pod and RDS PostgreSQL.
8. Phase 6 — AWS ALB Ingress
Why Ingress?
Our ClusterIP Services are reachable only through Kubernetes networking. Customers need an external HTTP entry point.
AWS Load Balancer Controller can create and manage an Application Load Balancer from a Kubernetes Ingress.
Before proceeding, verify that the AWS Load Balancer Controller is installed, has appropriate IAM permissions and that the VPC subnets are correctly tagged for load balancer discovery.
kubectl get deployment aws-load-balancer-controller \
-n kube-system
kubectl get ingressclass
If the controller is missing, do not assume that creating an Ingress will automatically provision an ALB. The instructor must first install the controller using the supported AWS installation procedure and IAM configuration.
8.1 Create the Ingress
Create ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: realestate-ingress
namespace: real-estate-lab
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
Apply:
kubectl apply -f ingress.yaml
kubectl get ingress -n real-estate-lab
kubectl describe ingress realestate-ingress \
-n real-estate-lab
The ADDRESS field should eventually contain an ALB hostname if provisioning succeeds.
Important production distinction
This is an HTTP-only classroom configuration. Do not send real customer information through it. For a real application, configure HTTPS using ACM, an HTTPS listener, appropriate redirect rules and an approved DNS name.
The single ALB routes /api to Backend and / to Frontend. The frontend uses relative /api requests, so browser requests reach the same ALB hostname.
8.2 Verify
Once the ALB hostname is assigned:
export ALB_DNS=$(kubectl get ingress realestate-ingress \
-n real-estate-lab \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "$ALB_DNS"
curl -i "http://$ALB_DNS/"
curl -i "http://$ALB_DNS/api/properties"
If you see 502, 503 or 504, do not immediately restart the Pods. Inspect the ALB target groups, backend health, Service endpoints, application logs and database connectivity.
9. Phase 7 — PostgreSQL StatefulSet with EBS
This phase is a separate experiment from the RDS database.
Our main Real Estate Company uses RDS. Now we will deploy another PostgreSQL instance inside Kubernetes to understand StatefulSet, PVC, PV, StorageClass and the EBS CSI driver.
9.1 Understand the storage path
StatefulSet
Creates and manages postgres-0
Pod
Runs the PostgreSQL database software
PVC
Requests 5 GiB of persistent storage
StorageClass
Selects the EBS storage provisioner
EBS CSI Driver
Coordinates EBS volume provisioning and attachment
PV / EBS Volume
Stores PostgreSQL data files
9.2 Check EBS CSI and StorageClasses
kubectl get csidrivers
kubectl get storageclass -o wide
For a standard EKS installation with the EBS CSI driver, the CSI provisioner is typically:
ebs.csi.aws.com
For EKS Auto Mode, the provisioner is:
ebs.csi.eks.amazonaws.com
Use the driver and StorageClass that actually match your cluster.
If EBS CSI is missing, the instructor must install the appropriate EKS add-on and configure its AWS IAM permissions. The CSI controller needs permissions to provision and manage EBS volumes. Merely creating a PVC will not grant those permissions.
For standard EKS with the standard EBS CSI driver, create storageclass.yaml:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: realestate-gp3
provisioner: ebs.csi.aws.com
parameters:
type: gp3
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
For EKS Auto Mode, use its supported provisioner instead and review the applicable configuration before creating the class.
Apply:
kubectl apply -f storageclass.yaml
Why WaitForFirstConsumer? EBS volumes are Availability Zone-specific. Kubernetes should consider the Pod's placement before provisioning the volume.
Why reclaimPolicy: Delete? This is a disposable classroom lab. Deleting the PVC may lead to deletion of its dynamically provisioned EBS volume. Do not use this configuration for valuable data without understanding its consequences.
9.3 Create the PostgreSQL credentials
read -rs -p "Lab PostgreSQL password: " PG_PASSWORD
echo
kubectl create secret generic postgres-lab-secret \
-n real-estate-lab \
--from-literal=POSTGRES_USER=labuser \
--from-literal=POSTGRES_PASSWORD="$PG_PASSWORD" \
--from-literal=POSTGRES_DB=labdb
unset PG_PASSWORD
9.4 Create PostgreSQL StatefulSet
Create postgres-statefulset.yaml:
apiVersion: v1
kind: Service
metadata:
name: postgres-headless
namespace: real-estate-lab
spec:
clusterIP: None
selector:
app: postgres-lab
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: real-estate-lab
spec:
serviceName: postgres-headless
replicas: 1
selector:
matchLabels:
app: postgres-lab
template:
metadata:
labels:
app: postgres-lab
spec:
securityContext:
fsGroup: 999
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
name: postgres
envFrom:
- secretRef:
name: postgres-lab-secret
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 10
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: realestate-gp3
resources:
requests:
storage: 5Gi
Why one replica? Creating three PostgreSQL Pods does not automatically configure PostgreSQL replication. We are demonstrating persistence and stable identity, not building a highly available database cluster.
Apply:
kubectl apply -f postgres-statefulset.yaml
kubectl get statefulset -n real-estate-lab
kubectl get pods -n real-estate-lab -l app=postgres-lab
kubectl get pvc -n real-estate-lab
kubectl get pv
Wait until postgres-0 is Running/Ready and the PVC is Bound.
9.5 Create test data
Open PostgreSQL:
kubectl exec -it postgres-0 \
-n real-estate-lab -- \
psql -U labuser -d labdb
Inside psql:
CREATE TABLE property_notes (
id SERIAL PRIMARY KEY,
property_name TEXT NOT NULL,
note TEXT NOT NULL
);
INSERT INTO property_notes (property_name, note)
VALUES ('Chicago Condo', 'Customer requested a tour');
SELECT * FROM property_notes;
Exit:
\q
9.6 Delete the PostgreSQL Pod
kubectl delete pod postgres-0 -n real-estate-lab
Wait:
kubectl rollout status statefulset/postgres \
-n real-estate-lab --timeout=300s
Check the data again:
kubectl exec postgres-0 -n real-estate-lab -- \
psql -U labuser -d labdb \
-c "SELECT * FROM property_notes;"
Expected: The saved record remains because PostgreSQL is using its persistent volume.
Student explanation
The StatefulSet recreated postgres-0 with its stable identity. The PVC remained associated with the StatefulSet ordinal, and Kubernetes remounted the existing persistent storage.
If the underlying EBS volume is deleted, corrupted or unavailable, StatefulSet alone cannot recover the data.
10. Phase 8 — DaemonSet and centralized logging
What is DaemonSet responsible for?
A DaemonSet controller ensures that an eligible worker node runs a Pod for a particular node-level workload.
Typical examples include log collectors, monitoring agents and some networking components.
A DaemonSet does not replace kubelet, and it does not automatically centralize logs. We need a log collector and a destination.
For this project:
Fluent Bit DaemonSet → Amazon CloudWatch Logs
10.1 Understand the architecture
Worker Node 1
Application Pods
Fluent Bit Pod
Worker Node 2
Application Pods
Fluent Bit Pod
Amazon CloudWatch Logs
Centralized application and container logs
10.2 Verify IAM permissions
The Fluent Bit workload needs permission to create log streams and publish log events.
For standard EKS, prefer a dedicated IAM role for the collector using EKS Pod Identity or IRSA, according to your cluster configuration. Do not give every application Pod broad CloudWatch permissions.
A minimum illustrative policy for an instructor-created CloudWatch log group is:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": [
"arn:aws:logs:us-east-1:ACCOUNT_ID:log-group:/jumptotech/real-estate:*"
]
}
]
}
Replace ACCOUNT_ID with the actual AWS account ID. The instructor must configure the trust relationship and attach this policy to the collector's dedicated role.
Create the log group:
aws logs create-log-group \
--log-group-name /jumptotech/real-estate \
--region "$AWS_REGION"
If the log group already exists, AWS returns an already-exists error; that does not mean logging is broken.
Set a short retention period for the lab:
aws logs put-retention-policy \
--log-group-name /jumptotech/real-estate \
--retention-in-days 7 \
--region "$AWS_REGION"
10.3 Install Fluent Bit with Helm
For a real centralized-logging lab, use the maintained Fluent Bit Helm chart rather than writing an incomplete logging DaemonSet from scratch.
helm repo add fluent https://fluent.github.io/helm-charts
helm repo update
helm show values fluent/fluent-bit > fluent-default-values.yaml
The Helm chart must be configured for your EKS runtime log paths, CloudWatch output and IAM integration. Do not assume that the default values automatically send logs to CloudWatch.
Create fluent-values.yaml:
kind: DaemonSet
serviceAccount:
create: true
name: fluent-bit-realestate
config:
service: |
[SERVICE]
Flush 5
Daemon Off
Log_Level info
Parsers_File /fluent-bit/etc/parsers.conf
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
inputs: |
[INPUT]
Name tail
Path /var/log/containers/*real-estate-lab*.log
Tag kube.*
Mem_Buf_Limit 5MB
Skip_Long_Lines On
Refresh_Interval 10
DB /var/log/flb_realestate.db
filters: |
[FILTER]
Name kubernetes
Match kube.*
Kube_Tag_Prefix kube.var.log.containers.
Merge_Log On
Keep_Log Off
outputs: |
[OUTPUT]
Name cloudwatch_logs
Match kube.*
region us-east-1
log_group_name /jumptotech/real-estate
log_stream_prefix fluentbit-
auto_create_group false
Install only after the Fluent Bit ServiceAccount has been connected to the appropriate AWS IAM role. With EKS Pod Identity, this requires a Pod Identity association for the ServiceAccount; with IRSA, the ServiceAccount needs the role annotation and a valid OIDC trust policy.
The chart's default hostPath mounts must also be verified against your node runtime. The above values are a reference configuration to be validated, not a guarantee that every EKS node image exposes identical log paths.
helm upgrade --install realestate-logs \
fluent/fluent-bit \
-n logging --create-namespace \
-f fluent-values.yaml
Check:
kubectl get daemonsets -n logging
kubectl get pods -n logging -o wide
kubectl logs -n logging \
-l app.kubernetes.io/name=fluent-bit \
--tail=50
10.4 Verify centralized logs
Generate some backend traffic:
curl "http://$ALB_DNS/api/properties"
Then inspect CloudWatch:
aws logs describe-log-streams \
--log-group-name /jumptotech/real-estate \
--region "$AWS_REGION"
Read recent logs:
aws logs tail /jumptotech/real-estate \
--since 10m \
--region "$AWS_REGION"
If no logs appear, inspect the Fluent Bit Pod logs, IAM permissions, log paths, filter configuration and output plugin errors.
Student question: Why do we use DaemonSet rather than a Deployment with three replicas for a node-level log collector?
Answer: A DaemonSet ties desired Pod placement to eligible nodes. A Deployment controls replica count but does not guarantee one collector on every node.
11. Phase 9 — Terraform and infrastructure ownership
Terraform is an infrastructure-as-code tool. It can provision the AWS infrastructure that Kubernetes applications depend on.
For this lab, divide ownership as follows:
| Resource | Managed by | Responsible team |
|---|---|---|
| VPC, subnets, security groups | Terraform / AWS | Cloud / DevOps |
| EKS cluster and node groups | Terraform / AWS | Platform / DevOps |
| ECR repositories | Terraform / AWS | DevOps |
| RDS instance and subnet group | Terraform / AWS | Cloud / DBA |
| AWS IAM roles and policies | Terraform / AWS | Cloud Security / DevOps |
| Frontend and backend code | Git | Application developers |
| Deployments and Services | Kubernetes manifests / Helm | DevOps |
| PostgreSQL StatefulSet and PVC | Kubernetes | DevOps / DBA |
| EBS volumes | EBS CSI Driver / AWS | Kubernetes storage integration |
| Fluent Bit | Helm / DaemonSet | Platform / SRE |
| Database schema and migrations | SQL migration tooling | Backend / DBA |
Terraform example: RDS security group
This example demonstrates how to restrict PostgreSQL access to the EKS worker-node security group.
Create terraform/rds-security.tf in a separate Terraform project:
variable "vpc_id" {
type = string
}
variable "eks_node_security_group_id" {
type = string
}
resource "aws_security_group" "rds" {
name_prefix = "realestate-rds-"
description = "RDS access from EKS worker nodes"
vpc_id = var.vpc_id
tags = {
Project = "JumpToTech-RealEstate"
}
}
resource "aws_vpc_security_group_ingress_rule" "postgres" {
security_group_id = aws_security_group.rds.id
referenced_security_group_id = var.eks_node_security_group_id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
resource "aws_vpc_security_group_egress_rule" "rds_egress" {
security_group_id = aws_security_group.rds.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
This is only one component of the RDS Terraform configuration, not a complete standalone infrastructure deployment. The VPC, provider, DB subnet group, RDS instance, credential management and Terraform backend still need to be configured.
Important: Students must never run terraform apply against an existing shared EKS infrastructure project without understanding its state and proposed changes.
12. Phase 10 — Production troubleshooting exercises
Each student must investigate these scenarios independently and explain the findings.
| Scenario | Symptoms | First checks |
|---|---|---|
| ImagePullBackOff | Pod cannot start | Image URI, ECR tag, pull permissions |
| CrashLoopBackOff | Container repeatedly exits |
kubectl logs --previous, events |
| Backend not Ready | Readiness probe fails |
/readyz, RDS connectivity, credentials |
| Service unavailable | No backend endpoints | Selectors, Pod labels, EndpointSlices |
| ALB 502 | ALB cannot get valid backend response | Target health, ports, app process |
| ALB 503 | No available targets or other service issue | Target groups, endpoints, health |
| ALB 504 | Backend response timeout | Application latency, networking, RDS |
| PVC Pending | PostgreSQL cannot obtain storage | StorageClass, CSI driver, IAM, scheduling |
| PostgreSQL Pod Pending | Pod cannot schedule | Node capacity, AZ constraints, volume events |
| Fluent Bit no logs | CloudWatch log group empty | IAM, input path, collector logs, output errors |
Troubleshooting commands
kubectl get all -n real-estate-lab
kubectl get pods -n real-estate-lab -o wide
kubectl describe deployment realestate-backend \
-n real-estate-lab
kubectl logs deployment/realestate-backend \
-n real-estate-lab --tail=100
kubectl get svc -n real-estate-lab
kubectl get endpointslices -n real-estate-lab
kubectl get events -n real-estate-lab \
--sort-by=.lastTimestamp
kubectl get pvc -n real-estate-lab
kubectl describe pvc postgres-data-postgres-0 \
-n real-estate-lab
kubectl get daemonsets -A
kubectl get ingress -n real-estate-lab
Simulated production incident
Your instructor will introduce a problem.
Incident: Customers can open the Real Estate Company website, but submitting an inquiry fails.
Students must:
- Verify whether the frontend is available.
- Check the backend HTTP response.
- Inspect backend Pod readiness.
- Check Service selectors and EndpointSlices.
- Examine application logs.
- Test PostgreSQL connectivity.
- Identify the root cause.
- Propose a safe correction.
- Verify that inquiries can be saved again.
- Write a short incident report.
Do not delete a namespace or restart all workloads as your first troubleshooting action.
13. Final student assessment
Student completion checklist
0/12 complete
Frontend and backend container images pushed to ECR
Frontend and backend Deployments running
ClusterIP Services and EndpointSlices verified
ALB routes frontend and backend traffic
RDS PostgreSQL connected to Backend
Customer inquiry saved and retrieved
PostgreSQL StatefulSet created
PVC Bound to an EBS-backed PV
PostgreSQL data survives Pod replacement
Fluent Bit DaemonSet deployed
CloudWatch receives application logs
One incident investigated and documented
Interview questions
Students should be able to answer these questions in English without reading notes.
- What is the difference between Deployment and StatefulSet?
- Why does the frontend use a Deployment?
- Why do we use ClusterIP Services?
- What is the difference between Amazon RDS and PostgreSQL in a StatefulSet?
- What happens if the PostgreSQL Pod is deleted?
- What is the difference between PV, PVC and StorageClass?
- What does the EBS CSI Driver do?
- Why is a DaemonSet suitable for a log collector?
- What is the difference between a DaemonSet controller and kubelet?
- How do you troubleshoot a backend Pod that is Running but not Ready?
- What is the purpose of AWS IAM in this architecture?
- How does a customer HTTP request travel from ALB to PostgreSQL?
14. Cleanup — prevent unnecessary AWS charges
Do not delete resources from a shared or production cluster. Confirm the namespace and ownership before cleanup.
First inspect:
kubectl get all -n real-estate-lab
kubectl get pvc -n real-estate-lab
kubectl get ingress -n real-estate-lab
Remove the ALB Ingress and verify that the AWS Load Balancer Controller has deleted the corresponding ALB:
kubectl delete ingress realestate-ingress \
-n real-estate-lab
Uninstall Fluent Bit if it was installed specifically for this lab:
helm uninstall realestate-logs -n logging
Remove the StatefulSet:
kubectl delete statefulset postgres \
-n real-estate-lab
The StatefulSet's PVC is generally retained when the StatefulSet is deleted. Inspect the PVC and PV before deleting storage:
kubectl get pvc -n real-estate-lab
kubectl get pv
If the PostgreSQL data is disposable and the PVC belongs to this lab:
kubectl delete pvc postgres-data-postgres-0 \
-n real-estate-lab
With the lab's Delete reclaim policy, this can cause the backing EBS volume to be deleted. Verify the actual EBS resource state in AWS.
After verifying that all resources in the namespace belong to this lab:
kubectl delete namespace real-estate-lab
Remove the lab-specific RDS instance, snapshots, CloudWatch log group, ECR images, IAM associations and other AWS resources only after verifying their ownership and backup requirements. Deleting the Kubernetes namespace does not automatically delete Amazon RDS.
Do not delete the EKS cluster if other students or projects use it.
15. What students must submit
Each student must submit a GitHub repository link and a short lab report containing evidence of the deployment, networking, database persistence, logging and troubleshooting.
The report should explain:
- Why the frontend and backend use Deployments.
- Why RDS is separate from Kubernetes.
- How StatefulSet, PVC, PV, StorageClass and EBS CSI work together.
- Why the log collector uses a DaemonSet.
- Which Kubernetes controllers are responsible for maintaining the desired state.
- What failed during troubleshooting and how the student verified recovery.
Students must not commit passwords, .env secrets, kubeconfig credentials or AWS access keys.
Final teaching outcome
By the end of this project, you should be able to explain the architecture of a multi-tier application on AWS EKS and demonstrate how its components work together.
The goal is not simply to create Pods. The goal is to understand application deployment, networking, database persistence, AWS permissions, observability and production troubleshooting.
Instructor implementation note: The application and Kubernetes examples above form a reference lab, but several infrastructure prerequisites are environment-specific: EKS IAM configuration, ALB controller installation, RDS networking and credentials, EBS CSI installation, and Fluent Bit AWS permissions. These must be validated against the actual student cluster before calling the deployment fully operational.
Top comments (0)