Skip to main content

Command Palette

Search for a command to run...

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

Published
โ€ข14 min read
End-to-End DevOps Project: Designing, Containerizing, and Deploying a 3-Tier Expense Tracker on AWS EKS
S
I'm Saurav Karki, a DevOps Engineer passionate about Kubernetes, AWS, and automation. Currently working as a "Devops Engineer Trainee", working with CI/CD pipelines, GitOps, and scalable architectures. I share projects, tutorials, and lessons from my DevOps journey to help others learn and grow."

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:

  1. Frontend โ†’ React.js app (UI for users)

  2. Backend โ†’ Node.js API (authentication, business logic)

  3. 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 as data.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.