End-to-End DevOps Project: Designing, Containerizing, and Deploying a 3-Tier Expense Tracker on AWS EKS

In todayโs DevOps world, itโs not just about writing code but about automating and deploying everythingโfrom infrastructure to deployments. To showcase my DevOps skills, I built a full-stack Expense Tracker application and deployed it on AWS EKS using Terraform, Kubernetes, and Docker.
This project is not just about tracking expenses โ itโs about showing how DevOps principles can be applied in a real-world application lifecycle:
Code โ Container โ Cloud
Automated infrastructure with Terraform
Application deployment with Kubernetes
Secure, production-ready setup on AWS EKS & RDS
This project covers the complete journey:
Designing a three-tier architecture
Containerizing the frontend, backend, and database
Automating infrastructure provisioning with Terraform
Deploying to EKS with Kubernetes manifests
Configuring Ingress, Load Balancer.
Making the app production-ready
๐๏ธ Architecture Overview
The Expense Tracker follows a Three-Tier Architecture:
Frontend โ React.js app (UI for users)
Backend โ Node.js API (authentication, business logic)
Database โ PostgreSQL (data storage, managed via RDS in production)
All these components are containerized with Docker and deployed on AWS EKS (Elastic Kubernetes Service).
At a high level:
User โ AWS ALB โ Frontend (React) โ Backend (Node.js) โ Database (PostgreSQL RDS)
๐ Project Structure
Hereโs how the project is organized:
Expense-Tracker-Three-Tier/
โ
โโโ README.md # Overview, instructions, architecture diagram
โ
โโโ app/ # Application source code
โ โโโ frontend/ # React frontend (UI)
โ โโโ backend/ # Node.js or Micronaut backend API
โ โโโ database/ # Database init scripts & migrations
โ
โโโ infra/ # Infrastructure as Code
โ โโโ terraform/ # Terraform configs for AWS infra
โ โ โโโ main.tf
โ โ โโโ variables.tf
โ โ โโโ outputs.tf
โ โ โโโ eks.tf # EKS cluster creation
โ โ โโโ rds.tf # RDS PostgreSQL (optional)
โ โ
โ โโโ k8s-manifests/ # Kubernetes manifests
โ โโโ namespace.yaml
โ โโโ deployment-frontend.yaml
โ โโโ deployment-backend.yaml
โ โโโ service-frontend.yaml
โ โโโ service-backend.yaml
| โโโ configmap-frontend.yaml
โ โโโ configmap-backend.yaml
| โโโ secret-backend.yaml
โ โโโ ingress.yaml
โจ Features
The Expense Tracker app comes with real-world features to make it more than just a โhello worldโ:
๐ Authentication โ JWT-based login & registration
๐ต Expense Management โ CRUD operations with categories (Food, Travel, Bills, etc.)
๐ Analytics Dashboard โ Monthly overview + category breakdown using charts
๐ Data Export โ Export expenses as CSV
๐ณ Containerized Deployment โ Run everything with Docker or Kubernetes
๐ณ Containerization
Each service (frontend, backend, database) has its own Dockerfile.
Backend Dockerfile:
# Use Node.js 18 (Debian-based) as base image
FROM node:18-slim
# Install Python (required for some npm packages)
RUN apt-get update && apt-get install -y python3 make g++
WORKDIR /app
# Set environment to development
ENV NODE_ENV=development
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 5000
# Start the application
CMD ["npm", "start"]
Frontend Dockerfile:
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Database Dockerfile:
FROM node:18-alpine
WORKDIR /app
# Install PostgreSQL client for healthcheck
RUN apk add --no-cache postgresql-client
# Copy package files first for better caching
COPY package*.json ./
COPY .sequelizerc .
# Install dependencies
RUN npm install --production
# Copy the wait-and-migrate script and make it executable
COPY wait-and-migrate.sh .
RUN chmod +x wait-and-migrate.sh
# Copy the rest of the application
COPY . .
# Set environment variables
ENV NODE_ENV=production
# Command to run migrations and keep container running
CMD ["/app/wait-and-migrate.sh"]
Shell script to run migration scripts when postgres is healthy:
#!/bin/sh
set -e
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c 'SELECT 1'; do
>&2 echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
# Run migrations
echo "Running migrations..."
npx sequelize-cli db:migrate
# Run seeders if needed
if [ "$NODE_ENV" = "development" ] || [ "$RUN_SEEDERS" = "true" ]; then
echo "Running seeders..."
npx sequelize-cli db:seed:all
fi
echo "Migrations completed successfully"
# Keep the container running
tail -f /dev/null
DockerCompose:
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: expense-tracker-db
environment:
POSTGRES_DB: expense_tracker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
ports:
- "5434:5432"
networks:
- expense-tracker-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Database migrations and seeding
database:
build:
context: ./database
dockerfile: Dockerfile
container_name: expense-tracker-migrations
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_USER=postgres
- DB_PASSWORD=password
- DB_NAME=expense_tracker
- DB_PORT=5432
- DATABASE_URL=postgres://postgres:password@postgres:5432/expense_tracker
depends_on:
postgres:
condition: service_healthy
networks:
- expense-tracker-network
restart: "on-failure"
# Backend API
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: expense-tracker-backend
environment:
- NODE_ENV=development
- PORT=5000
- DB_HOST=postgres
- DB_USER=postgres
- DB_PASSWORD=password
- DB_NAME=expense_tracker
- DB_PORT=5432
ports:
- "5002:5000"
depends_on:
- postgres
- database
networks:
- expense-tracker-network
volumes:
- ./backend/.env:/app/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
# Frontend React App
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: expense-tracker-frontend
ports:
- "3000:80"
depends_on:
- backend
networks:
- expense-tracker-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
volumes:
postgres_data:
driver: local
networks:
expense-tracker-network:
driver: bridge
Testing locally is as easy as:
docker-compose up -d
โ๏ธ Infrastructure with Terraform
Instead of manually setting up AWS resources, I automated everything with Terraform:
EKS Cluster โ Kubernetes control plane
Node Groups โ Worker nodes for pods
RDS PostgreSQL โ Managed database instance
Networking (VPC, Subnets, SGs)
Typical Terraform flow:
cd infra/terraform
terraform init
terraform plan -var="rds_password=YourStrongPassword123"
terraform apply -var="rds_password=YourStrongPassword123" -auto-approve
Here are the terraform HCL code for infrastructure setup:
infra/terraform/variables.tf
variable "aws_region" {
description = "AWS region to deploy resources in"
type = string
default = "us-east-1"
}
variable "eks_cluster_name" {
description = "Name of the EKS cluster"
type = string
default = "expensetracker"
}
variable "rds_instance_identifier" {
description = "RDS instance name"
type = string
default = "expense-tracker-db"
}
variable "rds_db_name" {
description = "Database name in RDS"
type = string
default = "expense_tracker"
}
variable "rds_username" {
description = "Master DB username"
type = string
default = "postgres"
}
variable "rds_password" {
description = "Master DB password"
type = string
sensitive = true
}
variable "rds_instance_class" {
description = "RDS instance type"
type = string
default = "db.t3.micro"
}
infra/terraform/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Shared default VPC and subnets for both EKS and RDS
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
filter {
name = "availability-zone"
values = ["us-east-1a", "us-east-1b", "us-east-1c"] # only supported ones
}
}
infra/terraform/eks.tf
# EKS Cluster IAM Role
resource "aws_iam_role" "eks_cluster_role" {
name = "${var.eks_cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Effect = "Allow",
Principal = {
Service = "eks.amazonaws.com"
},
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
role = aws_iam_role.eks_cluster_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
# EKS Cluster
resource "aws_eks_cluster" "this" {
name = var.eks_cluster_name
role_arn = aws_iam_role.eks_cluster_role.arn
vpc_config {
subnet_ids = data.aws_subnets.default.ids
}
depends_on = [aws_iam_role_policy_attachment.eks_cluster_policy]
}
# Node Group IAM Role
resource "aws_iam_role" "eks_node_role" {
name = "${var.eks_cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
},
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "node_AmazonEKSWorkerNodePolicy" {
role = aws_iam_role.eks_node_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_AmazonEC2ContainerRegistryReadOnly" {
role = aws_iam_role.eks_node_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_role_policy_attachment" "node_AmazonEKS_CNI_Policy" {
role = aws_iam_role.eks_node_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
# Node Group
resource "aws_eks_node_group" "default" {
cluster_name = aws_eks_cluster.this.name
node_group_name = "${var.eks_cluster_name}-node-group"
node_role_arn = aws_iam_role.eks_node_role.arn
subnet_ids = data.aws_subnets.default.ids
scaling_config {
desired_size = 2
max_size = 3
min_size = 1
}
instance_types = ["t2.medium"]
depends_on = [
aws_iam_role_policy_attachment.node_AmazonEKSWorkerNodePolicy,
aws_iam_role_policy_attachment.node_AmazonEC2ContainerRegistryReadOnly,
aws_iam_role_policy_attachment.node_AmazonEKS_CNI_Policy
]
}
# Shared Security Group for EKS nodes + RDS
resource "aws_security_group" "eks_rds_sg" {
name = "${var.eks_cluster_name}-eks-rds-sg"
description = "Allow communication between EKS nodes and RDS"
vpc_id = data.aws_vpc.default.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
self = true
description = "Allow PostgreSQL from EKS nodes"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
infra/terraform/rds.tf
# Security Group for RDS
resource "aws_security_group" "rds_sg" {
name = "rds-postgres-sg"
description = "Allow PostgreSQL access from EKS nodes"
vpc_id = data.aws_vpc.default.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [data.aws_vpc.default.cidr_block] # allow all nodes in VPC
description = "PostgreSQL access from EKS nodes"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Subnet group for RDS (uses the same filtered default-VPC subnets)
resource "aws_db_subnet_group" "default" {
name = "default-rds-subnet-group"
subnet_ids = data.aws_subnets.default.ids
}
# RDS PostgreSQL instance (no engine_version -> let AWS choose a supported one)
resource "aws_db_instance" "this" {
identifier = var.rds_instance_identifier
engine = "postgres"
instance_class = var.rds_instance_class
allocated_storage = 20
db_name = var.rds_db_name
username = var.rds_username
password = var.rds_password
publicly_accessible = false
skip_final_snapshot = true
db_subnet_group_name = aws_db_subnet_group.default.name
vpc_security_group_ids = [aws_security_group.rds_sg.id]
backup_retention_period = 7
storage_encrypted = true
deletion_protection = false
}
infra/terraform/outputs.tf
output "eks_cluster_endpoint" {
description = "EKS cluster endpoint"
value = aws_eks_cluster.this.endpoint
}
output "eks_cluster_name" {
description = "EKS cluster name"
value = aws_eks_cluster.this.name
}
output "rds_endpoint" {
description = "RDS PostgreSQL endpoint"
value = aws_db_instance.this.endpoint
}
output "rds_username" {
value = var.rds_username
}
output "rds_password" {
value = var.rds_password
sensitive = true
}
Once applied, I had a ready-to-use Kubernetes cluster connected to my AWS account.
๐ Kubernetes Deployment
With infrastructure ready, I deployed the app using Kubernetes manifests:
Namespace
apiVersion: v1
kind: Namespace
metadata:
name: my-app
Backend Deployment + Service
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: my-app
labels:
app: expense-tracker
tier: backend
spec:
replicas: 2
selector:
matchLabels:
app: expense-tracker
tier: backend
template:
metadata:
labels:
app: expense-tracker
tier: backend
spec:
containers:
- name: backend
image: sauravkarki/expense-tracker-new-backend:1.0.2
imagePullPolicy: Always
ports:
- containerPort: 5000
name: http
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "5000"
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: backend-secrets
key: JWT_SECRET
- name: JWT_EXPIRES_IN
value: "7d"
- name: DB_HOST
valueFrom:
secretKeyRef:
name: backend-secrets
key: DB_HOST
- name: DB_USER
valueFrom:
secretKeyRef:
name: backend-secrets
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: backend-secrets
key: DB_PASSWORD
- name: DB_NAME
valueFrom:
configMapKeyRef:
name: backend-config
key: DB_NAME
- name: DB_PORT
valueFrom:
configMapKeyRef:
name: backend-config
key: DB_PORT
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "250m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 5
periodSeconds: 5
Backend service:
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: my-app
labels:
app: expense-tracker
tier: backend
spec:
type: ClusterIP
selector:
app: expense-tracker
tier: backend
ports:
- name: http
protocol: TCP
port: 5000
targetPort: http
Frontend Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: my-app
labels:
app: expense-tracker
tier: frontend
spec:
replicas: 2
selector:
matchLabels:
app: expense-tracker
tier: frontend
template:
metadata:
labels:
app: expense-tracker
tier: frontend
spec:
containers:
- name: frontend
image: sauravkarki/expense-tracker-new-frontend:1.0.1
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
# In deployment-frontend.yaml
env:
- name: REACT_APP_API_URL
valueFrom:
configMapKeyRef:
name: frontend-config
key: REACT_APP_API_URL
- name: REACT_APP_NAME
valueFrom:
configMapKeyRef:
name: frontend-config
key: REACT_APP_NAME
- name: REACT_APP_VERSION
valueFrom:
configMapKeyRef:
name: frontend-config
key: REACT_APP_VERSION
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: frontend-config
key: NODE_ENV
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "100m"
memory: "128Mi"
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
Frontend service:
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: my-app
labels:
app: expense-tracker
tier: frontend
spec:
type: ClusterIP
selector:
app: expense-tracker
tier: frontend
ports:
- name: http
protocol: TCP
port: 80
targetPort: http
secret backend:
apiVersion: v1
kind: Secret
metadata:
name: backend-secrets
namespace: my-app
type: Opaque
data:
DB_HOST: "ZXhwZW5zZS10cmFja2VyLWRiLmNob2dteXdlNGFrMC51cy1lYXN0LTEucmRzLmFtYXpvbmF3cy5jb20=" # Base64 encoded RDS endpoint (without port)
DB_USER: "cG9zdGdyZXM=" # Base64 encoded RDS username
DB_PASSWORD: "UFVaR1JoZXdEM2x0dTQ=" # Base64 encoded RDS password
JWT_SECRET: "cf9db19c3475774e8ad00977461acd1103d724ff81558b9d0f2be5e4b2bde650736526b0319be3351b33442b3eb72c0fde37bd4242b42a0e0f8ffebad7e884f2"
NODE_ENV: cHJvZHVjdGlvbg== # base64 for "production"
configmap backend:
apiVersion: v1
kind: ConfigMap
metadata:
name: backend-config
namespace: my-app
data:
NODE_ENV: production
PORT: "5000"
JWT_SECRET: "" # Will be overridden by Secret
JWT_EXPIRES_IN: "7d"
# DB_HOST will be set in the deployment from the secret
DB_PORT: "5432"
DB_NAME: "expense_tracker"
configmap frontend:
# configmap-frontend.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-config
namespace: my-app
data:
REACT_APP_API_URL: "/api" # This will be handled by the Ingress
REACT_APP_NAME: "Expense Tracker"
REACT_APP_VERSION: "1.0.0"
NODE_ENV: "production" # Change to production for the deployed version
Apply the manifests:
kubectl apply -f namespace.yaml
kubectl apply -f *
By default, EKS does not ship with an Ingress controller. On AWS, the recommended way is to use the AWS Load Balancer Controller, which automatically provisions Application Load Balancers (ALB) when you create Ingress resources.
To set this up, we need to do some IAM role, policy, and controller installation steps.
1๏ธโฃ Create IAM roles, policies, and permissions
Since the AWS Load Balancer Controller interacts directly with AWS resources (like ALBs, Target Groups, Security Groups), it needs IAM permissions. Weโll configure this in three steps:
a) Associate IAM OIDC Provider with EKS
This step allows Kubernetes ServiceAccounts to assume AWS IAM roles.
eksctl utils associate-iam-oidc-provider \
--region us-east-1 \
--cluster expensetracker \
--approve
b) Create IAM Policy for the Controller
The controller requires specific AWS permissions (like creating ALBs, modifying target groups, etc.). AWS provides a pre-defined policy JSON.
curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/main/docs/install/iam_policy.json
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file://iam_policy.json
This creates a customer-managed IAM policy in your account.
c) Create IAM Role & Service Account for the Controller
We now bind this policy to a Kubernetes ServiceAccount in the kube-system namespace.
eksctl create iamserviceaccount \
--cluster expensetracker \
--namespace kube-system \
--name aws-load-balancer-controller \
--attach-policy-arn arn:aws:iam::<ACCOUNT_ID>:policy/AWSLoadBalancerControllerIAMPolicy \
--approve
๐ Replace
<ACCOUNT_ID>with your AWS account ID.
This ensures the controller pod running in Kubernetes has the correct AWS permissions to create and manage ALBs.
2๏ธโฃ Install the AWS Load Balancer Controller with Helm
Once IAM roles are ready, we install the controller into our cluster.
First, apply the CRDs:
kubectl apply -k "github.com/aws/eks-charts/stable/aws-load-balancer-controller//crds?ref=main"
Then, install via Helm:
helm repo add eks https://aws.github.io/eks-charts
helm repo update
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=expensetracker \
--set serviceAccount.create=false \
--set region=us-east-1 \
--set vpcId=<VPC_ID> \
--set serviceAccount.name=aws-load-balancer-controller
๐ Replace
<VPC_ID>with your VPC ID.
You can find this in Terraform asdata.aws_vpc.default.id.
3๏ธโฃ Verify the Controller is Running
kubectl get deployment -n kube-system aws-load-balancer-controller
You should see:
NAME READY UP-TO-DATE AVAILABLE AGE
aws-load-balancer-controller 1/1 1 1 2m
4๏ธโฃ Apply Your Ingress Resource
At this point, your existing ingress.yaml will automatically create an internet-facing ALB.
kubectl apply -f ingress.yaml
๐ Thatโs it! Now your frontend and backend can be accessed securely through the AWS ALB provisioned by the controller.
Ingress with ALB:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: expense-tracker
namespace: my-app
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /
alb.ingress.kubernetes.io/healthcheck-protocol: HTTP
alb.ingress.kubernetes.io/healthcheck-port: traffic-port
alb.ingress.kubernetes.io/backend-protocol: HTTP
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/ssl-redirect: '443'
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# Add these if you have an SSL certificate
# alb.ingress.kubernetes.io/certificate-arn: "your-certificate-arn"
# alb.ingress.kubernetes.io/ssl-policy: "ELBSecurityPolicy-TLS13-1-2-2021-06"
spec:
tls:
- hosts:
- expensetracker.sauravkarki.me
secretName: expense-tracker-tls
rules:
- host: expensetracker.sauravkarki.me # Add host specification
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 5000 # Updated to match your backend service port
This created an AWS Application Load Balancer (ALB) that routed traffic to the app.
๐ Security
JWT authentication for API access
Kubernetes Secrets for DB credentials, JWT keys
Ingress with TLS (ACM Certificate) for HTTPS
Namespace isolation to separate app workloads
Appendix:
Screenshots:







๐ฎ Future Improvements
While the current setup is production-ready, thereโs always room to evolve the architecture. Some powerful enhancements i will add:
1๏ธโฃ CI/CD with GitHub Actions
Automate builds, tests, and deployments so that every code push triggers a pipeline. This ensures:
Faster feedback loops
No manual deployment hassle
Consistent builds
๐ Example: A push to main branch automatically builds the frontend + backend Docker images, scans them for vulnerabilities, pushes them to Docker Hub, and deploys to EKS.
2๏ธโฃ GitOps with ArgoCD or Flux
Instead of manually applying Kubernetes manifests, adopt GitOps:
My Git repo becomes the single source of truth
ArgoCD/Flux continuously syncs Kubernetes with Git state
Any infra or app config change is auditable, versioned, and reproducible
๐ Just merge a PR โ ArgoCD deploys it โ Done โ
3๏ธโฃ Event-driven Autoscaling with KEDA
As this is a showcase project i havenโt setup any Horizontal pod Autoscaling and also no KEDA(Kubernetes event driven autoscaling) . I will be adding those features in future versions.
๐ Example: If you add a feature where users upload receipts, KEDA can scale backend pods automatically when the upload queue grows.
๐ฏ Conclusion
This project was more than just building an Expense Trackerโit was about demonstrating end-to-end DevOps practices:
From code to containers
From containers to cloud infrastructure
From cloud infrastructure to a production-ready deployment
As a DevOps engineer, I believe in automation, scalability, and reliability, and this project gave me the opportunity to showcase those principles in action.



