Build a Production Application Platform

Build a production-grade application platform from scratch - React frontend, Node.js API, PostgreSQL, Redis — containerised with Docker, deployed to Kubernetes with Ingress, NetworkPolicies, HPA, PDB, resource limits, health checks, Prometheus monitoring, and GitOps delivery via ArgoCD.

Domains & Technologies

Domains
CAPSTONENODEJSPOSTGRESQLREDISINGRESSHPAPDBNETWORKPOLICYFULL-STACKPRODUCTION
Technologies
KUBERNETESDOCKERPROMETHEUSARGOCD

Blueprint Walkthrough

Before You Start — Read This First

Most people learning Platform Engineering watch videos, read documentation, and follow tutorials one tool at a time. They learn Docker separately. Then Kubernetes separately. Then monitoring separately. Then GitOps separately. By the end they understand each tool in isolation — but they have never connected all of them together into something real.

This capstone changes that.

You are going to build a Production Application Platform — not just a deployed app, but the complete platform around it that makes it production-ready. The same architecture and standards that power Indian product companies like Razorpay, CRED, and Zerodha. You will write the application code, containerise it, deploy it to Kubernetes with proper security boundaries, set up auto-scaling, add monitoring, and connect it to GitOps delivery.

The difference between a lab project and this capstone is everything that surrounds the application:

  • NetworkPolicies — isolate the application so a compromised pod cannot reach other services
  • Resource limits — prevent one pod from starving others on the same node
  • PodDisruptionBudget — ensure availability during cluster maintenance
  • Health checks configured correctly (liveness vs readiness — different jobs)
  • HPA — scale automatically under load without manual intervention

When you finish this project, you will be able to say — and genuinely mean it — that you have built and operated a production-grade application platform. Not "I completed a tutorial." You built something real, with real production standards.

When you finish this project, you will be able to say — and genuinely mean it — that you have built and deployed a production-ready cloud-native application. Not "I completed a course." Not "I watched a tutorial." You built something real.

This single project covers:

  • Docker and containers
  • Kubernetes workloads, services, config, and secrets
  • Ingress and traffic routing
  • Auto-scaling with HPA
  • Prometheus and Grafana monitoring
  • ArgoCD GitOps delivery

Time to complete: 4-6 hours if you follow every step carefully.


What You Are Building

Before writing a single line of code, understand the big picture. Here is what the finished application looks like:

◈ DIAGRAM
Someone opens the app in their browser
|
v
Nginx Ingress Controller
(the single entry point — like a reception desk)
|
+-----+-----+
| |
v v
/ (frontend) /api (backend)
React App Node.js API
port 3000 port 4000
|
+-----+-----+
| |
v v
PostgreSQL Redis
(stores data) (cache layer)
port 5432 port 6379

Think of it like a restaurant:

  • React frontend — the menu customers look at and order from
  • Node.js API — the waiter who takes orders and talks to the kitchen
  • PostgreSQL — the kitchen's order book where everything is written down permanently
  • Redis — a whiteboard near the kitchen where recent orders are written for quick access so the waiter does not have to check the order book every single time
  • Nginx Ingress — the front door that directs customers to the right place

This is called a three-tier architecture because it has three layers:

  1. Presentation layer (frontend — what users see)
  2. Application layer (API — business logic)
  3. Data layer (database + cache — where data lives)

This pattern is used everywhere. Understanding it deeply means you can work on almost any company's infrastructure.


What You Need Before Starting

You need these tools installed on your computer. Each one has a short explanation of what it is and why you need it:

Docker — the tool that packages your application into a container. A container is like a box that contains your application AND everything it needs to run (the runtime, libraries, configuration). Without Docker you cannot build the container images.

kubectl — the command-line tool for talking to Kubernetes. Think of it like a TV remote — Kubernetes is the TV (the cluster), kubectl is how you control it. You will use it constantly throughout this project.

A Kubernetes cluster — the environment where everything runs. For this project, use one of:

  • minikube — runs Kubernetes on your laptop (best for beginners, free, easy to set up)
  • kind — also runs Kubernetes locally, slightly faster
  • Any cloud Kubernetes — EKS on AWS, GKE on Google Cloud, AKS on Azure (costs money but is closer to production)

Node.js 18+ — needed to write and test the backend locally before containerising it.

Git — needed for the GitOps section at the end.

Bash
## Verify everything is installed
docker --version
kubectl version --client
node --version
git --version
## Start minikube if using locally
minikube start --memory=4096 --cpus=2
## Verify kubectl can reach your cluster
kubectl get nodes
## You should see a node in "Ready" status

Part 1 — The Application Code

What Are We Building in This Part?

Before we touch Docker or Kubernetes, we write the actual application. This is important — too many platform engineering tutorials skip straight to deploying a pre-built app. That teaches you deployment but not the connection between code and infrastructure.

We are building an order management system (inspired by food delivery apps). It has:

  • A backend API that creates and lists orders
  • A database that stores orders permanently
  • A cache that stores recent orders temporarily for faster access
  • Health check endpoints that Kubernetes uses to know if the app is working

Why a Three-Tier App Specifically?

Because every real application you will encounter as a Platform Engineer uses this pattern. Payment services, e-commerce platforms, dashboards, internal tools — they all have a frontend, an API, and a database. Once you understand how to deploy this pattern, you understand how to deploy almost anything.

1.1 Create the Project Structure

Bash
## Create the main project folder
mkdir swiggy-clone && cd swiggy-clone
## Initialise git immediately — good habit
git init
## Create the folder structure
mkdir -p backend/src/routes
mkdir -p frontend/src
mkdir -p k8s/database
mkdir -p k8s/cache
mkdir -p k8s/backend
mkdir -p k8s/frontend
mkdir -p k8s/ingress
mkdir -p k8s/monitoring
mkdir -p .github/workflows
echo "✅ Project structure created"

Your project will look like this when finished:

◈ DIAGRAM
swiggy-clone/
backend/ ← Node.js API code
frontend/ ← React app code
k8s/ ← All Kubernetes manifests
.github/workflows/ ← CI/CD pipeline

Separating application code from infrastructure code (the k8s/ folder) is a best practice. When something breaks in production, you know exactly where to look — application bug? Check backend/. Deployment issue? Check k8s/.

1.2 Backend — The Node.js API

What Is Node.js and Why Are We Using It?

Node.js is a runtime that lets you run JavaScript on the server (not just in browsers). It is popular for APIs because it handles many simultaneous requests efficiently. We are using it because it is simple enough to understand quickly but realistic enough to represent what you will see at actual companies.

What Is PostgreSQL?

PostgreSQL (often called Postgres) is a relational database. Think of it as a very organised Excel spreadsheet that can handle millions of rows, relationships between tables, and thousands of simultaneous reads and writes. This is where orders are stored permanently — even if the application restarts, the data survives because it is written to disk.

What Is Redis?

Redis is an in-memory data store. In-memory means data lives in RAM (very fast) instead of on disk (slower). It is used as a cache — when the API receives a request for all orders, instead of asking PostgreSQL every single time (which involves disk reads and processing), it first checks Redis. If the data is there, it returns it instantly. If not, it asks PostgreSQL and then stores the result in Redis for next time.

This matters because at scale, a single popular endpoint might receive thousands of requests per second. Without caching, every request hits the database. With caching, only the first request hits the database — everyone else gets the cached version.

Setting Up the Backend

Bash
cd backend
## npm init creates a package.json file — the manifest for a Node.js project
## -y means "accept all defaults"
npm init -y
## Install the libraries we need:
## express → the web framework (handles HTTP requests and routing)
## pg → PostgreSQL client (lets Node.js talk to PostgreSQL)
## redis → Redis client (lets Node.js talk to Redis)
## dotenv → loads environment variables from a .env file
## cors → allows the frontend to call the API from a browser
## helmet → adds security headers to responses
## morgan → logs every incoming request (useful for debugging)
npm install express pg redis dotenv cors helmet morgan
## nodemon restarts the server automatically when you change code
## --save-dev means it is only used during development, not in production
npm install --save-dev nodemon
echo "✅ Backend dependencies installed"

The Database Connection File

This file creates a connection pool to PostgreSQL. A connection pool is a group of database connections that stay open and get reused. Instead of opening and closing a connection for every request (slow), the pool keeps connections ready to use.

JAVASCRIPT
// backend/src/db.js
const { Pool } = require('pg');
// Pool reads connection details from environment variables
// We never hardcode passwords here — they come from Kubernetes Secrets
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'appdb',
user: process.env.DB_USER || 'appuser',
password: process.env.DB_PASSWORD,
// Pool settings:
max: 10, // keep at most 10 connections open
idleTimeoutMillis: 30000, // close idle connections after 30 seconds
connectionTimeoutMillis: 2000, // fail fast if DB is unreachable
});
// Test the connection when the application starts
// If the database is not reachable, crash immediately with a clear error
// This is called "fail fast" — better to fail at startup than silently later
pool.connect((err, client, release) => {
if (err) {
console.error('❌ Database connection failed:', err.message);
process.exit(1); // exit code 1 = error
}
console.log('✅ Database connected');
release(); // return the connection to the pool
});
module.exports = pool;

The Redis Connection File

JAVASCRIPT
// backend/src/cache.js
const redis = require('redis');
const client = redis.createClient({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
// retry_strategy controls what happens when Redis is temporarily unavailable
// Instead of crashing immediately, we try again with increasing delays
retry_strategy: (options) => {
if (options.attempt > 10) {
// After 10 attempts, stop trying
return undefined;
}
// Wait longer between each attempt (100ms, 200ms, 300ms... up to 3 seconds)
return Math.min(options.attempt * 100, 3000);
}
});
client.on('connect', () => console.log('✅ Redis connected'));
client.on('error', (err) => console.error('Redis error:', err.message));
module.exports = client;

The Orders API Routes

This file defines what the API does when it receives requests. In REST API terms, a route is a combination of an HTTP method (GET, POST, etc.) and a URL path (/api/orders). Each route has a handler function that runs when a matching request arrives.

JAVASCRIPT
// backend/src/routes/orders.js
const express = require('express');
const router = express.Router();
const pool = require('../db');
const cache = require('../cache');
// ─────────────────────────────────────────────────────────────
// GET /api/orders — return list of all orders
//
// Flow:
// 1. Check Redis cache
// 2. If cache hit → return cached data (fast)
// 3. If cache miss → query PostgreSQL, store in cache, return data
// ─────────────────────────────────────────────────────────────
router.get('/', async (req, res) => {
try {
// Step 1: Check cache
// cache.get is callback-based, so we wrap it in a Promise
const cached = await new Promise((resolve, reject) => {
cache.get('orders:all', (err, data) => {
if (err) reject(err);
else resolve(data); // data is null if key does not exist
});
});
if (cached) {
// Cache hit — return immediately without touching the database
return res.json({
source: 'cache', // tells the client where data came from
data: JSON.parse(cached) // Redis stores strings, so parse back to JSON
});
}
// Step 2: Cache miss — query the database
const result = await pool.query(
'SELECT * FROM orders ORDER BY created_at DESC LIMIT 50'
);
// Step 3: Store result in cache for 60 seconds
// After 60 seconds Redis automatically deletes this entry
cache.setex('orders:all', 60, JSON.stringify(result.rows));
res.json({ source: 'database', data: result.rows });
} catch (err) {
console.error('GET /orders error:', err);
// 500 = Internal Server Error
res.status(500).json({ error: 'Failed to fetch orders' });
}
});
// ─────────────────────────────────────────────────────────────
// POST /api/orders — create a new order
// ─────────────────────────────────────────────────────────────
router.post('/', async (req, res) => {
const { item, quantity, customer_name } = req.body;
// Validate input before touching the database
// Always validate on the server — never trust what the client sends
if (!item || !quantity || !customer_name) {
// 400 = Bad Request — the client sent incomplete data
return res.status(400).json({
error: 'item, quantity, and customer_name are required'
});
}
try {
// $1, $2, $3 are parameterised placeholders — prevents SQL injection
const result = await pool.query(
`INSERT INTO orders (item, quantity, customer_name, status)
VALUES ($1, $2, $3, 'pending')
RETURNING *`, // RETURNING * sends back the created row
[item, quantity, customer_name]
);
// Invalidate the cache — the "all orders" list just changed
// Next GET will fetch fresh data from the database
cache.del('orders:all');
// 201 = Created (more accurate than 200 for creation)
res.status(201).json(result.rows[0]);
} catch (err) {
console.error('POST /orders error:', err);
res.status(500).json({ error: 'Failed to create order' });
}
});
// ─────────────────────────────────────────────────────────────
// GET /api/orders/:id — get a single order by ID
// ─────────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM orders WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
// 404 = Not Found — the order with this ID does not exist
return res.status(404).json({ error: 'Order not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('GET /orders/:id error:', err);
res.status(500).json({ error: 'Failed to fetch order' });
}
});
module.exports = router;

The Health Check Routes — Why These Are Critical

Health checks are one of the most important concepts in Kubernetes. Kubernetes needs to know two things about every pod:

Is the process alive? — If the Node.js process has crashed or frozen, Kubernetes should restart it. This is the liveness probe.

Is it ready to receive traffic? — Even if the process is running, it might not be ready yet. Maybe the database connection has not been established. Maybe it is still loading data. Until it is ready, Kubernetes should not send it any traffic. This is the readiness probe.

A common beginner mistake is making both probes check the same thing. The key insight is:

  • Liveness = "Is this pod alive enough to stay running?" → keep it simple, just ping the server
  • Readiness = "Is this pod ready to serve users?" → check all dependencies
JAVASCRIPT
// backend/src/routes/health.js
const express = require('express');
const router = express.Router();
const pool = require('../db');
const cache = require('../cache');
// ─────────────────────────────────────────────────────────────
// Liveness probe — used by Kubernetes to know if the pod is alive
//
// Rule: NEVER check dependencies here
// If the database goes down, we do NOT want Kubernetes to restart
// all our pods — that would make the outage worse, not better
// Just confirm the Node.js process is responding
// ─────────────────────────────────────────────────────────────
router.get('/live', (req, res) => {
res.status(200).json({ status: 'alive' });
});
// ─────────────────────────────────────────────────────────────
// Readiness probe — used by Kubernetes to know if the pod
// is ready to receive user traffic
//
// Rule: check ALL dependencies
// If database OR Redis is down, we return 503
// Kubernetes removes this pod from the load balancer
// until dependencies recover
// ─────────────────────────────────────────────────────────────
router.get('/ready', async (req, res) => {
const checks = {};
// Check PostgreSQL
try {
await pool.query('SELECT 1'); // lightweight query just to test connectivity
checks.database = 'ok';
} catch (err) {
checks.database = err.message;
}
// Check Redis
try {
await new Promise((resolve, reject) => {
cache.ping((err, result) => {
if (err) reject(err);
else resolve(result);
});
});
checks.cache = 'ok';
} catch (err) {
checks.cache = err.message;
}
// If all checks pass → 200 (ready)
// If any check fails → 503 (not ready)
const allHealthy = Object.values(checks).every(v => v === 'ok');
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'ready' : 'not_ready',
checks // shows exactly which dependency failed
});
});
module.exports = router;

The Main Server File — Wiring Everything Together

JAVASCRIPT
// backend/src/index.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
// Import our route handlers
const ordersRouter = require('./routes/orders');
const healthRouter = require('./routes/health');
const app = express();
const PORT = process.env.PORT || 4000;
// ─── Middleware ────────────────────────────────────────────────
// Middleware is code that runs on every request before it reaches
// the route handler. Think of it as a security and logging layer.
app.use(helmet()); // adds security headers (prevents common attacks)
app.use(cors()); // allows the React frontend to call this API
app.use(morgan('combined')); // logs every request: method, path, status, time
app.use(express.json()); // parses JSON request bodies
// ─── Routes ───────────────────────────────────────────────────
app.use('/api/orders', ordersRouter); // /api/orders → ordersRouter
app.use('/health', healthRouter); // /health/live and /health/ready
// ─── Error Handlers ───────────────────────────────────────────
// Catch requests to routes that do not exist
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.path} not found` });
});
// Catch unhandled errors from any route
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// ─── Start Server ─────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`✅ API server running on port ${PORT}`);
});
JSON
// backend/package.json — replace the scripts section with this
{
"name": "swiggy-clone-backend",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"helmet": "^7.0.0",
"morgan": "^1.10.0",
"pg": "^8.11.0",
"redis": "^3.1.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

Database Schema

Before the API can store orders, the database needs a table to store them in. This SQL file creates the table and adds some sample data so the app has something to show immediately.

SQL
-- backend/src/init.sql
-- This runs automatically when PostgreSQL starts for the first time
-- CREATE TABLE IF NOT EXISTS means: only create this table if it does not exist yet
-- This makes the script safe to run multiple times
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY, -- auto-incrementing unique ID
item VARCHAR(255) NOT NULL, -- what was ordered (max 255 chars)
quantity INTEGER NOT NULL CHECK (quantity > 0), -- must be positive
customer_name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- default value
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -- auto-set timestamp
);
-- Indexes make queries faster by creating a lookup table
-- Without an index, "WHERE status = 'pending'" scans every row
-- With an index, it jumps directly to matching rows
CREATE INDEX IF NOT EXISTS idx_orders_status
ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_created_at
ON orders(created_at DESC);
-- Sample data — Indian food delivery inspired
INSERT INTO orders (item, quantity, customer_name, status) VALUES
('Butter Chicken', 2, 'Arjun Sharma', 'delivered'),
('Paneer Tikka', 1, 'Priya Nair', 'preparing'),
('Biryani', 3, 'Vikram Das', 'pending'),
('Masala Dosa', 2, 'Ananya Reddy', 'delivered'),
('Chole Bhature', 1, 'Rohit Gupta', 'preparing')
ON CONFLICT DO NOTHING; -- do not fail if data already exists

1.3 Frontend — The React Application

What Is React and Why Are We Using It?

React is a JavaScript library for building user interfaces. It lets you break a UI into reusable components — pieces of the page that manage their own data and rendering. We are building a simple one-page app that lists orders and lets you create new ones.

The frontend does not talk to the database directly — it only talks to the API. This separation is fundamental: the frontend should never know about the database. It just knows about the API.

Bash
cd ../frontend
## create-react-app creates a complete React project structure
npx create-react-app . --template minimal
## axios is a library that makes HTTP requests simpler
## We use it to call our Node.js API
npm install axios
echo "✅ Frontend created"
React JSX
// frontend/src/App.jsx
// The entire frontend in one file — simple and clear
import React, { useState, useEffect } from 'react';
import axios from 'axios';
// API_BASE is where we send requests
// In production (inside Kubernetes), /api gets proxied to the backend by Nginx
// During local development, we point directly to localhost:4000
const API_BASE = process.env.REACT_APP_API_URL || '/api';
function App() {
// useState creates reactive variables
// When these change, React automatically re-renders the affected parts
const [orders, setOrders] = useState([]); // list of orders
const [loading, setLoading] = useState(true); // are we fetching data?
const [error, setError] = useState(null); // any error to show?
const [form, setForm] = useState({
item: '', quantity: 1, customer_name: ''
});
// useEffect runs code when the component mounts (first appears on screen)
// The empty [] means "run once when the page loads"
useEffect(() => {
fetchOrders();
}, []);
const fetchOrders = async () => {
try {
setLoading(true);
const response = await axios.get(`${API_BASE}/orders`);
setOrders(response.data.data);
setError(null);
} catch (err) {
setError('Could not load orders. Is the API running?');
} finally {
setLoading(false); // always runs — whether request succeeded or failed
}
};
const createOrder = async (e) => {
e.preventDefault(); // prevent page reload (default form behaviour)
try {
await axios.post(`${API_BASE}/orders`, form);
// Reset the form after successful submission
setForm({ item: '', quantity: 1, customer_name: '' });
fetchOrders(); // refresh the list to show the new order
} catch (err) {
setError(err.response?.data?.error || 'Failed to create order');
}
};
// Color coding for order status
const statusColor = {
pending: '#FFA500', // orange
preparing: '#3B82F6', // blue
delivered: '#22C55E', // green
};
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24,
background: '#111827', minHeight: '100vh', color: '#F3F4F6' }}>
<h1 style={{ color: '#F97316', borderBottom: '2px solid #F97316',
paddingBottom: 16 }}>
🍛 Order Management
</h1>
{/* ── New Order Form ────────────────────────────────── */}
<div style={{ background: '#1F2937', padding: 24,
borderRadius: 8, marginBottom: 32 }}>
<h2 style={{ marginTop: 0, color: '#F3F4F6' }}>Place New Order</h2>
<form onSubmit={createOrder}
style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<input
placeholder="Item (e.g. Biryani)"
value={form.item}
onChange={e => setForm({ ...form, item: e.target.value })}
required
style={{ padding: '10px 12px', borderRadius: 6, border: 'none',
flex: 1, minWidth: 140, fontSize: 14 }}
/>
<input
type="number" min="1" placeholder="Qty"
value={form.quantity}
onChange={e => setForm({ ...form, quantity: e.target.value })}
required
style={{ padding: '10px 12px', borderRadius: 6, border: 'none',
width: 70, fontSize: 14 }}
/>
<input
placeholder="Your name"
value={form.customer_name}
onChange={e => setForm({ ...form, customer_name: e.target.value })}
required
style={{ padding: '10px 12px', borderRadius: 6, border: 'none',
flex: 1, minWidth: 140, fontSize: 14 }}
/>
<button type="submit"
style={{ background: '#F97316', color: 'white', border: 'none',
padding: '10px 20px', borderRadius: 6, cursor: 'pointer',
fontWeight: 600, fontSize: 14 }}>
Place Order
</button>
</form>
{error && (
<p style={{ color: '#EF4444', marginTop: 12, marginBottom: 0 }}>
⚠️ {error}
</p>
)}
</div>
{/* ── Orders List ───────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'space-between',
alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>
All Orders {!loading && `(${orders.length})`}
</h2>
<button onClick={fetchOrders}
style={{ background: 'transparent', border: '1px solid #F97316',
color: '#F97316', padding: '6px 14px', borderRadius: 6,
cursor: 'pointer' }}>
Refresh
</button>
</div>
{loading && <p style={{ color: '#9CA3AF' }}>Loading orders...</p>}
{orders.map(order => (
<div key={order.id}
style={{ background: '#1F2937', padding: '16px 20px',
borderRadius: 8, marginBottom: 8,
display: 'flex', justifyContent: 'space-between',
alignItems: 'center' }}>
<div>
<strong style={{ fontSize: 16 }}>
{order.item} × {order.quantity}
</strong>
<p style={{ color: '#9CA3AF', margin: '4px 0 0 0', fontSize: 14 }}>
{order.customer_name}
</p>
</div>
<span style={{
background: statusColor[order.status] || '#6B7280',
color: 'white', padding: '4px 14px',
borderRadius: 20, fontSize: 13, fontWeight: 500
}}>
{order.status}
</span>
</div>
))}
</div>
);
}
export default App;

Part 2 — Containerisation

What Is a Container and Why Do We Need It?

Right now the backend runs on your laptop. It works because your laptop has Node.js 18 installed, the right version of npm, and the right operating system libraries.

The problem: the Kubernetes cluster does not know any of this. You cannot just copy your code to the cluster and run it.

A container solves this by packaging your application together with everything it needs to run — the runtime (Node.js 18), the libraries (express, pg, redis), the configuration. The result is a self-contained unit that runs identically on your laptop, on a Kubernetes cluster, on AWS, on GCP, anywhere.

A Docker image is the blueprint for creating containers. It is like a recipe. A container is the running instance created from that blueprint. Like a recipe vs the actual cake.

A Dockerfile is the file that tells Docker how to build the image.

What Is a Multi-Stage Build and Why Do We Use It?

A naive Dockerfile would install everything — development tools, test frameworks, build tools — and ship all of that to production. The resulting image would be enormous (500MB+) and contain tools that do not belong in production.

A multi-stage build uses two separate stages:

  1. Builder stage — installs everything needed to build the app
  2. Runner stage — contains only what is needed to run the app

The final image is much smaller and does not contain build tools. Smaller images are faster to pull, faster to start, and have a smaller attack surface for security vulnerabilities.

2.1 Backend Dockerfile

Dockerfile
# backend/Dockerfile
# ── Stage 1: Install Dependencies ─────────────────────────────
# node:18-alpine is a minimal Node.js image based on Alpine Linux
# Alpine is a tiny Linux distribution (5MB vs Ubuntu's 72MB)
FROM node:18-alpine AS deps
WORKDIR /app
# Copy package files first — before copying source code
# Docker builds in layers, and each instruction creates a layer
# If package.json has not changed, Docker reuses the cached layer
# This makes rebuilds much faster when you only changed your code
COPY package*.json ./
# npm ci is like npm install but:
# - reads package-lock.json exactly (reproducible builds)
# - --only=production skips devDependencies (nodemon not needed in production)
RUN npm ci --only=production
# ── Stage 2: Final Production Image ───────────────────────────
FROM node:18-alpine AS runner
WORKDIR /app
# Security best practice: never run application code as root
# If an attacker exploits a vulnerability, they get a limited user
# not full root access to the container
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001
# Copy from the builder stage — only production dependencies
# --chown sets the owner so nodeuser can read these files
COPY --from=deps --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs src/ ./src/
# Switch to the non-root user
USER nodeuser
# Tell Docker which port this container uses
# This is documentation — it does not actually open the port
EXPOSE 4000
# Health check built into the image
# Docker (and Kubernetes) can use this to know if the container is healthy
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:4000/health/live || exit 1
# The command that runs when the container starts
CMD ["node", "src/index.js"]

2.2 Frontend Dockerfile and Nginx Config

The frontend needs two things:

  1. Build the React app into static HTML, CSS, and JavaScript files
  2. Serve those static files using Nginx

Why Nginx and not Node.js? Nginx is purpose-built for serving static files. It is much more efficient than any Node.js server for this task and handles thousands of simultaneous connections with minimal memory.

PYTHON
# frontend/nginx.conf
# This is the Nginx configuration that runs inside the frontend container
server {
listen 80;
# Serve React app files from this directory
location / {
root /usr/share/nginx/html;
index index.html;
# This line is crucial for React Router
# React is a single-page app — all routes (/orders, /profile, etc.)
# are handled by JavaScript, not by actual files on disk
# Without this, refreshing /orders gives a 404
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend service
# When the frontend calls /api/orders, Nginx forwards it to the backend
# In Kubernetes, 'backend-service' resolves via internal DNS
location /api {
proxy_pass http://backend-service:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
# Simple health endpoint — Kubernetes probes check this
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}
Dockerfile
# frontend/Dockerfile
# ── Stage 1: Build the React app ──────────────────────────────
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# npm run build compiles React into static files
# Output goes to /app/build directory
RUN npm run build
# ── Stage 2: Serve with Nginx ─────────────────────────────────
FROM nginx:alpine AS runner
# Copy the built React files into the directory Nginx serves from
COPY --from=builder /app/build /usr/share/nginx/html
# Replace the default Nginx config with our custom one
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2.3 Test Everything Locally First

Before touching Kubernetes, verify the application works correctly with Docker Compose. This is an important step — debugging on your laptop is much faster than debugging inside Kubernetes.

What is Docker Compose? Docker Compose is a tool for running multiple containers together on your local machine. It reads a docker-compose.yml file and starts all the services defined in it. Think of it as "Kubernetes lite" — simpler, local-only, not for production.

YAML
# docker-compose.yml — LOCAL TESTING ONLY, not for production
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: devpassword123
ports:
- "5432:5432" # expose to localhost for debugging
volumes:
# Persist data between restarts
- postgres_data:/var/lib/postgresql/data
# Run init.sql automatically on first start
- ./backend/src/init.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
backend:
build: ./backend
ports:
- "4000:4000"
environment:
DB_HOST: postgres
DB_NAME: appdb
DB_USER: appuser
DB_PASSWORD: devpassword123
REDIS_HOST: redis
PORT: 4000
depends_on:
- postgres
- redis
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
volumes:
postgres_data:
Bash
## Build and start all services
docker-compose up --build
## Wait about 30 seconds for everything to start, then test:
## Test backend health
curl http://localhost:4000/health/ready
## Expected: {"status":"ready","checks":{"database":"ok","cache":"ok"}}
## Test API
curl http://localhost:4000/api/orders
## Expected: {"source":"database","data":[...5 sample orders...]}
## Create a new order
curl -X POST http://localhost:4000/api/orders \
-H "Content-Type: application/json" \
-d '{"item":"Dosa","quantity":2,"customer_name":"Test User"}'
## Expected: {"id":6,"item":"Dosa",...}
## Fetch again — this time should come from cache
curl http://localhost:4000/api/orders
## Expected: {"source":"cache","data":[...]}
## Notice "source":"cache" — Redis is working!
## Open http://localhost:3000 in your browser
## You should see the order management UI with orders listed
## Stop everything
docker-compose down
echo "✅ Local testing complete"

2.4 Build and Push Docker Images to a Registry

A container registry is a storage service for Docker images. When Kubernetes needs to start a container, it pulls the image from the registry. Common registries include Docker Hub (free, public), AWS ECR, Google GCR, and GitHub Container Registry.

Bash
## Set your registry details
## Replace 'your-dockerhub-username' with your actual Docker Hub username
export REGISTRY=your-dockerhub-username
export VERSION=v1.0.0
## Login to Docker Hub first
docker login
## Build and tag images
## The tag format is: registry/image-name:version
docker build -t ${REGISTRY}/swiggy-backend:${VERSION} ./backend
docker build -t ${REGISTRY}/swiggy-backend:latest ./backend
docker build -t ${REGISTRY}/swiggy-frontend:${VERSION} ./frontend
docker build -t ${REGISTRY}/swiggy-frontend:latest ./frontend
## Push to the registry
docker push ${REGISTRY}/swiggy-backend:${VERSION}
docker push ${REGISTRY}/swiggy-backend:latest
docker push ${REGISTRY}/swiggy-frontend:${VERSION}
docker push ${REGISTRY}/swiggy-frontend:latest
echo "✅ Images pushed to ${REGISTRY}"
echo "✅ Now update image references in k8s/backend/deployment.yaml"
echo " and k8s/frontend/deployment.yaml with your registry name"

Part 3 — Kubernetes Manifests

What Is Kubernetes and Why Are We Moving to It?

Docker Compose worked great locally. So why switch to Kubernetes?

Docker Compose runs on one machine. If that machine fails, everything is down. It also cannot automatically scale when traffic increases, cannot restart crashed containers intelligently, cannot manage secrets securely, and has no way to route traffic based on URL paths.

Kubernetes is a container orchestration platform — a system that manages containers across multiple machines, handles failures, scales automatically, manages configuration and secrets, and routes traffic intelligently.

When you run an application in Kubernetes:

  • If a container crashes, Kubernetes restarts it automatically
  • If a machine fails, Kubernetes moves the containers to another machine
  • If traffic increases, Kubernetes can start more copies of your container
  • If you push a new version, Kubernetes updates containers one by one with zero downtime

Everything in Kubernetes is defined in YAML manifest files. These files describe what you want — not how to achieve it. Kubernetes figures out the how. This is called declarative configuration — you declare the desired state and Kubernetes makes it happen.

The Building Blocks — What Each Resource Type Does

Before writing the manifests, understand what each Kubernetes resource type is:

Namespace — a virtual boundary inside a cluster. Think of it as a folder. All resources for our application live in one namespace, keeping them separate from other applications sharing the same cluster.

Secret — stores sensitive information (passwords, API keys, certificates) encrypted in Kubernetes. Applications read from Secrets instead of having passwords hardcoded in code.

ConfigMap — stores non-sensitive configuration (like the init.sql script) as key-value pairs. Keeps configuration separate from application code.

PersistentVolumeClaim — requests storage from the cluster. Like ordering a hard drive. The database needs persistent storage so data survives pod restarts.

StatefulSet — a way to run pods that need stable identities and persistent storage. Databases use StatefulSets, not Deployments, because they need the data to stay attached to the right pod.

Deployment — the standard way to run stateless application pods. Manages replicas, rolling updates, and rollbacks.

Service — a stable network endpoint for reaching pods. Pods come and go (they restart, get replaced, scale up and down) — their IP addresses change. A Service provides a fixed address that always routes to the right pods.

Ingress — routes external traffic into the cluster based on URL paths. The single entry point to the application.

HorizontalPodAutoscaler (HPA) — automatically adjusts the number of pod replicas based on CPU or memory usage. When traffic spikes, it adds pods. When traffic drops, it removes them.

PodDisruptionBudget (PDB) — ensures a minimum number of pods stay running during planned disruptions (like Kubernetes upgrades or node maintenance).

3.1 Namespace — Creating Our Application's Home

YAML
# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: swiggy-clone
labels:
# Labels are key-value pairs attached to any Kubernetes resource
# They are used for filtering, selecting, and policy targeting
app: swiggy-clone
environment: production
Bash
kubectl apply -f k8s/namespace.yaml
## Verify it was created
kubectl get namespaces | grep swiggy-clone

3.2 PostgreSQL — The Persistent Data Layer

PostgreSQL needs several Kubernetes resources working together. Let us understand each one before looking at the YAML.

Why StatefulSet and not Deployment? Deployments treat pods as interchangeable — any pod can be replaced by any other. For stateless apps (APIs, frontends) this is perfect. For databases it is catastrophic. If Kubernetes replaces a database pod, it needs to attach the data from the old pod to the new one. StatefulSets provide this guarantee — each pod has a stable identity and stays attached to its storage.

Why PersistentVolumeClaim? By default, when a pod is deleted, everything inside it is deleted too — including any data the database wrote to disk. A PVC requests storage that exists independently of the pod. Even if the pod is deleted and recreated, the same storage is reattached.

YAML
# k8s/database/postgres.yaml
---
# ── Secret: Database Credentials ──────────────────────────────
# Never put passwords in Deployment YAML or environment variables directly
# Secrets are stored encrypted in Kubernetes
# In production use External Secrets Operator to pull from Vault or AWS Secrets Manager
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: swiggy-clone
type: Opaque
stringData:
# stringData accepts plain text — Kubernetes base64-encodes it automatically
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ChangeThisInProduction123 # ← change this!
---
# ── PersistentVolumeClaim: Request Storage ──────────────────
# We request 5GB of storage from the cluster
# Kubernetes allocates it from the available storage (cloud disk, local disk, etc.)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: swiggy-clone
spec:
accessModes:
- ReadWriteOnce # one pod can read and write at a time (suitable for single-replica DB)
resources:
requests:
storage: 5Gi
---
# ── ConfigMap: Database Init Script ────────────────────────
# Store the SQL file as a ConfigMap so we can mount it into the PostgreSQL pod
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-init
namespace: swiggy-clone
data:
init.sql: |
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
item VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
customer_name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
INSERT INTO orders (item, quantity, customer_name, status) VALUES
('Butter Chicken', 2, 'Arjun Sharma', 'delivered'),
('Paneer Tikka', 1, 'Priya Nair', 'preparing'),
('Biryani', 3, 'Vikram Das', 'pending')
ON CONFLICT DO NOTHING;
---
# ── StatefulSet: PostgreSQL Pod ─────────────────────────────
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: swiggy-clone
spec:
serviceName: postgres # must match the headless Service name below
replicas: 1
selector:
matchLabels:
app: postgres # StatefulSet manages pods with this label
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
# envFrom injects all keys from the Secret as environment variables
# DB_NAME, DB_USER, DB_PASSWORD are available inside the container
envFrom:
- secretRef:
name: postgres-secret
resources:
requests:
cpu: "250m" # 250 millicores = 0.25 CPU cores
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
volumeMounts:
# Mount persistent storage at the path PostgreSQL uses for data
- name: postgres-data
mountPath: /var/lib/postgresql/data
# Mount init.sql to the init directory — PostgreSQL runs scripts here on first start
- name: init-script
mountPath: /docker-entrypoint-initdb.d
# Liveness: is PostgreSQL accepting connections?
livenessProbe:
exec:
command: ["pg_isready", "-U", "appuser", "-d", "appdb"]
initialDelaySeconds: 30 # wait 30s before first check (DB needs time to start)
periodSeconds: 10
# Readiness: same check — is PostgreSQL ready for queries?
readinessProbe:
exec:
command: ["pg_isready", "-U", "appuser", "-d", "appdb"]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc # reference the PVC we created above
- name: init-script
configMap:
name: postgres-init # reference the ConfigMap with init.sql
---
# ── Headless Service: Required for StatefulSet ─────────────
# A headless Service (clusterIP: None) is required by StatefulSets
# It gives each pod a stable DNS name: postgres-0.postgres.swiggy-clone.svc.cluster.local
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: swiggy-clone
spec:
clusterIP: None # headless — no load balancing, just DNS
selector:
app: postgres
ports:
- port: 5432
---
# ── Regular Service: For Application Access ─────────────────
# This is what the backend connects to
# "postgres-service" resolves to the PostgreSQL pod via Kubernetes DNS
apiVersion: v1
kind: Service
metadata:
name: postgres-service
namespace: swiggy-clone
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432

3.3 Redis — The Cache Layer

Redis is stateless from an application standpoint — if Redis restarts, the cache is empty and the application falls back to querying PostgreSQL. So we use a regular Deployment (not StatefulSet) for Redis.

YAML
# k8s/cache/redis.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: swiggy-clone
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
# --appendonly yes enables persistence (writes to disk)
# Without this, Redis data is lost on restart
command: ["redis-server", "--appendonly", "yes"]
ports:
- containerPort: 6379
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
livenessProbe:
exec:
command: ["redis-cli", "ping"] # redis-cli ping returns PONG if Redis is alive
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: redis-service
namespace: swiggy-clone
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379

3.4 Backend API Deployment

A Deployment manages a set of identical pods. You tell it how many replicas you want and it ensures that many are always running. If a pod crashes, the Deployment controller creates a new one. If you update the image, it does a rolling update — replacing pods one by one so there is always at least one running.

YAML
# k8s/backend/deployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: swiggy-clone
labels:
app: backend
spec:
replicas: 2 # two copies for redundancy — if one crashes, the other serves traffic
selector:
matchLabels:
app: backend # this Deployment manages pods with label app=backend
template:
metadata:
labels:
app: backend
annotations:
# These annotations tell Prometheus to scrape metrics from this pod
# Prometheus reads these annotations and automatically discovers the target
prometheus.io/scrape: "true"
prometheus.io/port: "4000"
prometheus.io/path: "/metrics"
spec:
# Security context applies to the entire pod
securityContext:
runAsNonRoot: true # reject any container that tries to run as root
runAsUser: 1001 # run as the user we created in the Dockerfile
containers:
- name: backend
# Replace 'your-registry' with your actual Docker Hub username
image: your-registry/swiggy-backend:v1.0.0
ports:
- containerPort: 4000
# ── Environment Variables ────────────────────────────
# Static config values go here directly
env:
- name: PORT
value: "4000"
- name: DB_HOST
value: postgres-service # Kubernetes DNS resolves this to the PostgreSQL Service
- name: DB_PORT
value: "5432"
- name: REDIS_HOST
value: redis-service # Kubernetes DNS resolves this to the Redis Service
- name: REDIS_PORT
value: "6379"
# Sensitive values come from the Secret
# envFrom injects all Secret keys as environment variables
envFrom:
- secretRef:
name: postgres-secret # DB_NAME, DB_USER, DB_PASSWORD
# ── Resource Management ──────────────────────────────
# requests = guaranteed minimum — Kubernetes uses this for scheduling
# limits = hard maximum — pod is killed if it exceeds this
resources:
requests:
cpu: "100m" # 0.1 CPU cores guaranteed
memory: "128Mi"
limits:
cpu: "500m" # never use more than 0.5 CPU cores
memory: "512Mi"
# ── Health Checks ─────────────────────────────────────
# livenessProbe: "is this pod alive?"
# If it fails 3 times in a row, Kubernetes restarts the pod
livenessProbe:
httpGet:
path: /health/live # just returns 200, no dependencies checked
port: 4000
initialDelaySeconds: 15 # wait 15s after start before first check
periodSeconds: 10 # check every 10s
failureThreshold: 3 # restart after 3 consecutive failures
# readinessProbe: "is this pod ready to receive traffic?"
# If it fails, Kubernetes removes this pod from the load balancer
# Traffic only goes to pods that pass the readiness probe
readinessProbe:
httpGet:
path: /health/ready # checks database and Redis connectivity
port: 4000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2 # remove from load balancer after 2 failures
---
# ── Service: Stable Network Endpoint ──────────────────────────
# The backend Deployment creates pods with random names (backend-abc123, backend-xyz456)
# The Service provides a stable name 'backend-service' that always routes to running pods
# Even when pods are replaced, the Service name stays the same
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: swiggy-clone
spec:
selector:
app: backend # route traffic to pods with this label
ports:
- name: http
port: 4000
targetPort: 4000

3.5 Frontend Deployment

The frontend is even simpler than the backend — it is a static file server. No database connections, no secrets, no complex health checks needed.

YAML
# k8s/frontend/deployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: swiggy-clone
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: your-registry/swiggy-frontend:v1.0.0 # replace with your image
ports:
- containerPort: 80
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: swiggy-clone
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80

3.6 Ingress — The Front Door

What Is Ingress and Why Does It Exist?

Without Ingress, you would need a separate LoadBalancer Service for every application. In cloud environments, each LoadBalancer creates a new cloud load balancer with its own IP address and cost. With 10 applications, that is 10 separate load balancers.

An Ingress Controller is a single load balancer that handles all incoming traffic and routes it to the right Service based on the URL path or hostname. One load balancer for all applications.

An Ingress resource is the YAML that defines the routing rules for your application.

Bash
## First install the Nginx Ingress Controller
## This creates the actual load balancer that handles incoming traffic
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml
## Wait for it to be ready
kubectl wait --for=condition=Ready pod \
-l app.kubernetes.io/component=controller \
-n ingress-nginx \
--timeout=120s
echo "✅ Ingress Controller ready"
YAML
# k8s/ingress/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: swiggy-clone-ingress
namespace: swiggy-clone
annotations:
# Tell Kubernetes to use the Nginx Ingress Controller
kubernetes.io/ingress.class: nginx
# Rate limiting — protect the API from being overwhelmed
# Maximum 10 requests per second per IP address
nginx.ingress.kubernetes.io/limit-rps: "10"
# Timeout settings — how long to wait for a backend response
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
rules:
- host: swiggy-clone.local # the domain name for our app
http:
paths:
# Rule 1: /api/* → backend service
# More specific paths are matched first
- path: /api
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 4000
# Rule 2: /health/* → backend service (for external health monitoring)
- path: /health
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 4000
# Rule 3: /* → frontend service (catch-all — everything else goes to React)
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
Bash
## Add the domain to your local hosts file so your browser can find it
## minikube users:
echo "$(minikube ip) swiggy-clone.local" | sudo tee -a /etc/hosts
## kind or local Kubernetes users:
echo "127.0.0.1 swiggy-clone.local" | sudo tee -a /etc/hosts

3.7 HPA — Automatic Scaling

What Is HPA and Why Does It Matter?

The problem with a fixed replica count: during peak hours (dinner time for a food delivery app) traffic spikes 10x. If you always run enough pods for peak traffic, you waste money during off-peak hours. If you run only enough for off-peak, your service degrades during peaks.

The HorizontalPodAutoscaler (HPA) solves this by automatically adjusting the replica count based on metrics. When CPU usage goes up, it adds pods. When CPU usage drops, it removes them.

It is called "horizontal" because it scales by adding more pods (horizontally) rather than making individual pods bigger (vertical scaling).

YAML
# k8s/backend/hpa.yaml
# Before HPA works, the Metrics Server must be installed
# kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
namespace: swiggy-clone
spec:
# Which Deployment to scale
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
minReplicas: 2 # never go below 2 (redundancy requirement)
maxReplicas: 10 # never go above 10 (cost control)
metrics:
# Metric 1: CPU utilisation
# When average CPU across all backend pods exceeds 70%, add more pods
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
# Metric 2: Memory utilisation
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
# Scale UP: add pods quickly when load increases
scaleUp:
stabilizationWindowSeconds: 30 # wait 30s before scaling up again
policies:
- type: Pods
value: 2 # add 2 pods at a time
periodSeconds: 30
# Scale DOWN: remove pods slowly to avoid flapping
# (adding and removing pods repeatedly)
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 minutes before scaling down
policies:
- type: Pods
value: 1 # remove 1 pod at a time
periodSeconds: 60

3.8 PodDisruptionBudget — Surviving Planned Maintenance

What Is a PDB and Why Do We Need It?

When Kubernetes needs to do maintenance — upgrade a node, drain a node for replacement — it evicts pods from that node. Without a PodDisruptionBudget, Kubernetes might evict all your pods simultaneously during maintenance, causing downtime.

A PDB says: "When you need to evict pods, always leave at least N running." This guarantees availability even during planned maintenance.

YAML
# k8s/backend/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: backend-pdb
namespace: swiggy-clone
spec:
# During maintenance, always keep at least 1 backend pod running
# If you have 2 replicas, this means maintenance can only evict 1 at a time
minAvailable: 1
selector:
matchLabels:
app: backend

3.9 NetworkPolicies — Security Boundaries Between Services

What Is a NetworkPolicy and Why Does Every Production App Need One?

By default, every pod in a Kubernetes cluster can reach every other pod. This sounds convenient but is a serious security problem.

Imagine the frontend pod gets compromised by an attacker. Without NetworkPolicies, that attacker can now reach the PostgreSQL database directly, bypass the API entirely, and dump all your data. The frontend never needed database access — it should only talk to the backend API.

A NetworkPolicy is a firewall rule for pods. It defines exactly which pods can talk to which other pods. The production pattern is:

  • Start with default-deny-all — nothing can talk to anything
  • Then explicitly allow only the connections that are needed

This is called the principle of least privilege — every pod has access to exactly what it needs and nothing more.

YAML
# k8s/network-policies.yaml
---
# Rule 1: Default deny — block ALL ingress and egress by default
# Apply this first, then add specific allow rules below
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: swiggy-clone
spec:
podSelector: {} # applies to ALL pods in this namespace
policyTypes:
- Ingress
- Egress
---
# Rule 2: Allow DNS resolution for all pods
# Without this, pods cannot resolve service names like 'postgres-service'
# DNS runs in kube-system namespace on port 53
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: swiggy-clone
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
---
# Rule 3: Allow Ingress traffic to reach the frontend
# Only the Ingress Controller can send traffic to the frontend pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-frontend
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
---
# Rule 4: Allow Ingress Controller to reach the backend API
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-backend
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
- ports:
- port: 4000
---
# Rule 5: Allow backend to reach PostgreSQL
# ONLY the backend pods can connect to the database
# Frontend pods CANNOT reach PostgreSQL directly
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-postgres
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: postgres # this policy protects the postgres pods
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend # only backend pods can connect
ports:
- port: 5432
---
# Rule 6: Allow backend to reach Redis
# Again — only backend, not frontend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-redis
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: redis
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- port: 6379
---
# Rule 7: Allow backend egress to reach postgres and redis
# (The rules above protect the destination — this allows the source to send)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-egress
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- port: 5432
- to:
- podSelector:
matchLabels:
app: redis
ports:
- port: 6379
---
# Rule 8: Allow Prometheus to scrape metrics from all pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scraping
namespace: swiggy-clone
spec:
podSelector:
matchLabels:
app: backend # prometheus scrapes the backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- port: 4000
Bash
## Apply the network policies
kubectl apply -f k8s/network-policies.yaml
## Verify policies are in place
kubectl get networkpolicies -n swiggy-clone
## Test that isolation works correctly:
## Frontend pod should NOT be able to reach PostgreSQL directly
kubectl exec -n swiggy-clone deployment/frontend -- \
wget -qO- --timeout=5 http://postgres-service:5432 2>&1 || \
echo "✅ Blocked — frontend cannot reach postgres directly"
## Backend pod SHOULD be able to reach PostgreSQL
kubectl exec -n swiggy-clone deployment/backend -- \
wget -qO- --timeout=5 http://postgres-service:5432 2>&1 | head -1 || \
echo "Backend postgres connectivity test done"
## API should still work end to end
curl http://swiggy-clone.local/api/orders
## Should return orders — network policies allow the correct flow
Security

NetworkPolicies require a CNI plugin that supports them — Calico or Cilium. If you are using a cloud Kubernetes service (EKS, GKE, AKS), this is already supported. If using minikube, start it with: minikube start --cni=calico


Part 4 — Deploy to Kubernetes

Why Order Matters

You cannot deploy the backend before the database exists. You cannot deploy the Ingress before the Services exist. Dependencies must be created first.

Bash
## Step 1: Create the namespace
kubectl apply -f k8s/namespace.yaml
## Step 2: Create the data layer (database and cache)
kubectl apply -f k8s/database/postgres.yaml
kubectl apply -f k8s/cache/redis.yaml
## Step 3: Wait for the database to be fully ready
## The --timeout=120s means "wait up to 2 minutes, then give up with an error"
echo "Waiting for PostgreSQL to be ready..."
kubectl wait --for=condition=Ready pod \
-l app=postgres \
-n swiggy-clone \
--timeout=120s
echo "Waiting for Redis to be ready..."
kubectl wait --for=condition=Ready pod \
-l app=redis \
-n swiggy-clone \
--timeout=60s
## Step 4: Deploy the application layer
kubectl apply -f k8s/backend/deployment.yaml
kubectl apply -f k8s/frontend/deployment.yaml
## Step 5: Set up scaling and availability
kubectl apply -f k8s/backend/hpa.yaml
kubectl apply -f k8s/backend/pdb.yaml
## Step 6: Set up traffic routing
kubectl apply -f k8s/ingress/ingress.yaml
echo "✅ All manifests applied"

Verify Everything is Working

Bash
## Check all pods are Running with READY=1/1
## READY=0/1 means the readiness probe is failing
kubectl get pods -n swiggy-clone
## Expected output:
## NAME READY STATUS RESTARTS AGE
## backend-6d7f9b8c6-abc12 1/1 Running 0 2m
## backend-6d7f9b8c6-xyz34 1/1 Running 0 2m
## frontend-7c8d9e0f1-pqr56 1/1 Running 0 2m
## frontend-7c8d9e0f1-stu78 1/1 Running 0 2m
## postgres-0 1/1 Running 0 3m
## redis-5f9b8c7d6-vwx90 1/1 Running 0 3m
## Check that Services were created
kubectl get services -n swiggy-clone
## Check that the Ingress has an address assigned
kubectl get ingress -n swiggy-clone
## The ADDRESS column should show an IP (may take 1-2 minutes to appear)
## Test the API directly (bypass Ingress — useful for debugging)
kubectl port-forward -n swiggy-clone svc/backend-service 4000:4000 &
curl http://localhost:4000/health/ready
## Expected: {"status":"ready","checks":{"database":"ok","cache":"ok"}}
curl http://localhost:4000/api/orders
## Expected: list of orders from the database
## Test the full flow through Ingress
curl http://swiggy-clone.local/api/orders
## Open in browser
echo "Open http://swiggy-clone.local in your browser"

Test Auto-Scaling

Bash
## Install hey — a simple HTTP load testing tool
## Mac:
brew install hey
## Linux:
wget https://hey-release.s3.us-east-2.amazonaws.com/hey_linux_amd64 -O hey
chmod +x hey && sudo mv hey /usr/local/bin/
## In one terminal: watch HPA react to load
kubectl get hpa -n swiggy-clone --watch
## In another terminal: generate load
hey -n 10000 -c 50 http://swiggy-clone.local/api/orders
## You should see the REPLICAS column in the HPA watch increase
## from 2 up to 4 or more as CPU utilisation rises
## When the load stops, wait ~5 minutes and watch replicas decrease back to 2

Part 5 — Monitoring with Prometheus and Grafana

What Is Prometheus and Why Do We Need It?

When the application is running, how do you know if it is healthy? How do you know if response times are increasing? How do you know if a pod is using too much memory?

Prometheus is a monitoring system that collects metrics from your applications and infrastructure. It pulls (scrapes) metrics from a /metrics endpoint on each pod at regular intervals and stores them as time-series data.

Grafana is a visualisation tool that connects to Prometheus and displays the metrics as dashboards and graphs.

Together they answer: "Is everything running well right now?" and "What was the application doing 2 hours ago when that alert fired?"

5.1 Install Prometheus and Grafana

Bash
## Add the Prometheus community Helm chart repository
helm repo add prometheus-community \
https://prometheus-community.github.io/helm-charts
helm repo update
## Install kube-prometheus-stack
## This is an all-in-one package that includes:
## - Prometheus (metrics collection)
## - Grafana (visualisation)
## - Alertmanager (alerting)
## - Node Exporter (system metrics from each node)
## - kube-state-metrics (Kubernetes object metrics)
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set grafana.adminPassword=admin123 \
--set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false
## Wait for everything to start (takes 2-3 minutes)
kubectl wait --for=condition=Ready pods \
--all -n monitoring --timeout=300s
echo "✅ Monitoring stack installed"

5.2 ServiceMonitor — Tell Prometheus About Our Backend

A ServiceMonitor is a Kubernetes resource that tells Prometheus which services to scrape for metrics. Without it, Prometheus does not know our backend exists.

YAML
# k8s/monitoring/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: backend-monitor
namespace: monitoring
labels:
# This label must match what the Prometheus installation looks for
release: monitoring
spec:
selector:
matchLabels:
app: backend # scrape Services with label app=backend
namespaceSelector:
matchNames:
- swiggy-clone # look in our application namespace
endpoints:
- port: http
path: /metrics # the endpoint Prometheus calls
interval: 15s # scrape every 15 seconds
Bash
kubectl apply -f k8s/monitoring/servicemonitor.yaml

5.3 PrometheusRule — Alerts When Things Go Wrong

Alerts are rules that fire when a metric crosses a threshold. Instead of manually watching dashboards, Prometheus evaluates these rules continuously and fires alerts when conditions are met.

YAML
# k8s/monitoring/alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: swiggy-clone-alerts
namespace: monitoring
labels:
release: monitoring
spec:
groups:
- name: swiggy-clone
interval: 30s # evaluate these rules every 30 seconds
rules:
# Alert 1: Backend has no ready pods
# expr uses PromQL — the Prometheus Query Language
# kube_deployment_status_replicas_ready is a metric exposed by kube-state-metrics
- alert: BackendPodsNotReady
expr: |
kube_deployment_status_replicas_ready{
deployment="backend",
namespace="swiggy-clone"
} < 1
for: 2m # alert only if the condition persists for 2 minutes (reduces noise)
labels:
severity: critical
annotations:
summary: "Backend has no ready pods — users cannot reach the API"
# Alert 2: PostgreSQL is down
- alert: DatabaseDown
expr: |
kube_pod_status_ready{
pod=~"postgres.*",
namespace="swiggy-clone"
} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "PostgreSQL pod is not ready — all database operations failing"
# Alert 3: HPA has reached maximum replicas
# This means the application is under heavy load and cannot scale further
- alert: HPAMaxedOut
expr: |
kube_horizontalpodautoscaler_status_current_replicas{
horizontalpodautoscaler="backend-hpa",
namespace="swiggy-clone"
}
==
kube_horizontalpodautoscaler_spec_max_replicas{
horizontalpodautoscaler="backend-hpa",
namespace="swiggy-clone"
}
for: 5m
labels:
severity: warning
annotations:
summary: "Backend HPA at maximum replicas — consider increasing maxReplicas"
Bash
kubectl apply -f k8s/monitoring/alerts.yaml

5.4 Access Grafana

Bash
## Port-forward Grafana to your local machine
kubectl port-forward -n monitoring svc/monitoring-grafana 3001:80
## Open http://localhost:3001 in your browser
## Username: admin
## Password: admin123
## Import pre-built dashboards:
## Click the + icon (left sidebar) → Import
## Enter Dashboard ID and click Load:
##
## 6417 → Kubernetes Pods (shows pod CPU, memory, restarts)
## 1860 → Node Exporter Full (shows node CPU, memory, disk)
## 14057 → Kubernetes API Overview
## You should see your backend pods appearing in the dashboards

Part 6 — GitOps with ArgoCD

What Is GitOps and Why Is It Better Than kubectl apply?

Right now you are running kubectl apply -f ... manually. This works but has problems:

  • If someone runs kubectl edit and changes something directly in the cluster, there is no record of it and the next kubectl apply overwrites it
  • If the cluster is destroyed and recreated, you have to remember all the files to apply and in what order
  • There is no history of what changed and why

GitOps solves this by making Git the single source of truth. You push changes to Git. An operator (ArgoCD) inside the cluster watches Git and applies changes automatically. The cluster always matches what is in Git.

Think of it like this: instead of you telling the cluster what to do, the cluster watches Git and keeps itself in sync.

6.1 Install ArgoCD

Bash
kubectl create namespace argocd
kubectl apply -n argocd \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl wait --for=condition=Ready pods \
--all -n argocd --timeout=300s
## Get the initial admin password (auto-generated on install)
ARGOCD_PASSWORD=$(kubectl get secret argocd-initial-admin-secret \
-n argocd \
-o jsonpath="{.data.password}" | base64 -d)
echo "ArgoCD password: ${ARGOCD_PASSWORD}"
## Access the UI
kubectl port-forward svc/argocd-server -n argocd 8080:443
## Open https://localhost:8080
## Username: admin Password: from above

6.2 Push Manifests to GitHub

Bash
## Create a new repository on GitHub called 'swiggy-clone-gitops'
## Then commit and push your k8s directory
git add k8s/
git commit -m "feat: add Kubernetes manifests for swiggy-clone"
git push origin main
echo "✅ Manifests pushed to GitHub"

6.3 Create the ArgoCD Application

An Application is an ArgoCD resource that connects a Git repository to a Kubernetes namespace. It tells ArgoCD: "Watch this repository, and keep this namespace in sync with whatever is there."

YAML
# argocd-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: swiggy-clone
namespace: argocd
# This finalizer means: when you delete this Application,
# also delete all the Kubernetes resources it manages
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
# Replace with your actual GitHub repository URL
repoURL: https://github.com/your-username/swiggy-clone-gitops
targetRevision: HEAD # always track the latest commit on main
path: k8s # directory in the repo containing manifests
destination:
server: https://kubernetes.default.svc # the cluster ArgoCD is running in
namespace: swiggy-clone
syncPolicy:
automated:
prune: true # delete resources that are removed from Git
selfHeal: true # revert manual kubectl changes back to Git state
syncOptions:
- CreateNamespace=true # create the namespace if it does not exist
Bash
kubectl apply -f argocd-app.yaml
## Watch ArgoCD sync
kubectl get application swiggy-clone -n argocd --watch
## STATUS: Synced
## HEALTH: Healthy
## TEST GitOps in action:
## Edit k8s/backend/deployment.yaml — change replicas from 2 to 3
## Commit and push:
git add k8s/backend/deployment.yaml
git commit -m "scale: increase backend replicas to 3"
git push
## Watch ArgoCD automatically detect the change and apply it
kubectl get pods -n swiggy-clone --watch
## You should see a third backend pod appear within ~3 minutes

Part 7 — Production Checklist

Every item in this checklist is something that real companies have been burned by in production. Check each one before calling this project done.

Bash
## ─── 1. All pods are Running and Ready ─────────────────────
kubectl get pods -n swiggy-clone
## Every pod should show READY=1/1 and STATUS=Running
## If any show 0/1 or CrashLoopBackOff, check logs first
## ─── 2. Health probes are working ───────────────────────────
kubectl describe pod -l app=backend -n swiggy-clone | \
grep -A5 "Liveness\|Readiness"
## Should show "Last Probe Result: Success"
## ─── 3. All pods have resource requests and limits ──────────
kubectl get pods -n swiggy-clone -o json | \
jq '.items[].spec.containers[] | {name: .name, resources: .resources}'
## Every container should have both requests and limits set
## ─── 4. Database is using persistent storage ─────────────────
kubectl get pvc -n swiggy-clone
## postgres-pvc should show STATUS=Bound
## ─── 5. Secrets are not hardcoded in deployment files ────────
kubectl get deployment backend -n swiggy-clone -o yaml | grep -i password
## Should not show any passwords — they should come from Secret references
## ─── 6. HPA is active and watching CPU metrics ───────────────
kubectl get hpa -n swiggy-clone
## Should show TARGETS (e.g. 23%/70%) and MINPODS/MAXPODS/REPLICAS
## ─── 7. PodDisruptionBudget exists ───────────────────────────
kubectl get pdb -n swiggy-clone
## backend-pdb should exist with MIN AVAILABLE = 1
## ─── 8. Ingress is routing traffic correctly ─────────────────
curl -s http://swiggy-clone.local/api/orders | jq '.source'
## Should return "database" or "cache"
curl -s -o /dev/null -w "%{http_code}" http://swiggy-clone.local/
## Should return 200
## ─── 9. ArgoCD shows Synced and Healthy ──────────────────────
kubectl get application swiggy-clone -n argocd
## SYNC STATUS: Synced HEALTH STATUS: Healthy
## ─── 10. Monitoring is scraping the backend ──────────────────
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090 &
sleep 2
curl -s "http://localhost:9090/api/v1/targets" | \
jq '.data.activeTargets[] | select(.labels.app=="backend") | .health'
## Should return "up"
echo ""
echo "✅ All checks passed — production checklist complete"

Common Production Mistakes

Using Deployment instead of StatefulSet for PostgreSQL 💥 The PostgreSQL Deployment restarts onto a different node. The PersistentVolumeClaim is ReadWriteOnce which means it can only attach to one node at a time. The new pod on the new node cannot attach to the existing volume. PostgreSQL fails to start. The error could not open file "base/1/pg_filenode.map" appears in logs. Data is inaccessible until someone manually intervenes. ✅ Always use StatefulSet for databases. StatefulSets guarantee stable pod identities and correct PVC attachment during scheduling and rescheduling.


Liveness probe checking database connectivity 💥 PostgreSQL becomes slow under high load — it is still running but responding slowly. The liveness probe calls /health/ready which queries PostgreSQL. The query takes longer than the probe timeout. The probe fails. Kubernetes restarts all backend pods. Each restarted pod also fails the liveness probe. Infinite restart loop. The backend is completely down even though only PostgreSQL was slow. ✅ Liveness probe calls /health/live which just returns 200 immediately. Only readiness probe checks dependencies. If the database is slow, the readiness probe fails, which removes pods from the load balancer — but does not restart them.


Forgetting resource requests — HPA cannot function 💥 A developer deploys a pod without setting resources.requests.cpu. The HPA is configured to scale at 70% CPU utilisation. HPA tries to calculate current utilisation but has no baseline (because no request was set). HPA logs missing request for cpu. Scaling never happens. During a traffic spike, pods run at 100% CPU but no new pods are created. ✅ resources.requests must be set on every container for HPA to work. The LimitRange we set earlier provides defaults — but explicitly setting requests is better practice.


Hardcoded password in deployment YAML committed to Git 💥 A developer writes DB_PASSWORD: mypassword123 directly in deployment.yaml and commits it. The repository is later made public or is accessed by someone without production access. The production database password is now compromised. Changing it requires updating the database, the Secret, and restarting all pods — while under active attack. ✅ Passwords go in Kubernetes Secrets only. In deployment YAML use secretRef or secretKeyRef to reference the Secret. Secrets should be managed by External Secrets Operator pulling from Vault or AWS Secrets Manager, never stored in Git.


No PodDisruptionBudget during node upgrade 💥 The cluster is being upgraded. Kubernetes drains a node to move pods elsewhere. It evicts all two backend pods simultaneously (because no PDB exists). For 30-60 seconds while pods restart on another node, the backend is completely down. Users get 503 errors. ✅ The PodDisruptionBudget with minAvailable: 1 tells Kubernetes it can only evict one pod at a time. The second pod stays running while the first moves to another node.


Debugging Playbook

Pod is in CrashLoopBackOff:

Bash
## CrashLoopBackOff means the container keeps crashing and Kubernetes
## keeps restarting it with increasing delays (1s, 2s, 4s, 8s...)
## Step 1: Read the logs from the crashed pod
## --previous shows logs from the last run (before the crash)
kubectl logs -n swiggy-clone deployment/backend --previous
## What to look for:
## "Cannot connect to database" or "ECONNREFUSED"
## → PostgreSQL is not reachable. Check:
kubectl get pods -n swiggy-clone -l app=postgres
kubectl get svc postgres-service -n swiggy-clone
## "DB_PASSWORD not set" or "undefined"
## → Secret is not mounted correctly. Check:
kubectl describe pod -l app=backend -n swiggy-clone | grep -A10 "Environment"
## "Error: listen EADDRINUSE: address already in use"
## → Port conflict. Usually means two processes trying to use port 4000
## → Check for duplicate deployments: kubectl get deployments -n swiggy-clone

Pod is stuck in Pending:

Bash
## Pending means Kubernetes cannot schedule the pod onto any node
## Step 1: Check why
kubectl describe pod <pod-name> -n swiggy-clone | tail -20
## What to look for:
## "Insufficient cpu" or "Insufficient memory"
## → No node has enough resources. Check:
kubectl describe nodes | grep -A5 "Allocated resources"
## "did not match node selector"
## → nodeSelector labels do not exist on any node. Check:
kubectl get nodes --show-labels

Ingress returns 502 Bad Gateway:

Bash
## 502 means Nginx received the request but could not reach the backend
## Step 1: Check Ingress controller logs
kubectl logs -n ingress-nginx \
deployment/ingress-nginx-controller --tail=50 | grep "error\|fail"
## Step 2: Check if backend pods are Ready
kubectl get pods -n swiggy-clone -l app=backend
## If READY=0/1 → readiness probe is failing → check /health/ready
## Step 3: Check the Service has endpoints (pods attached)
kubectl get endpoints backend-service -n swiggy-clone
## If ENDPOINTS shows <none> → no pods match the Service selector
## Check that pod labels match Service selector
## Step 4: Test backend directly (bypass Ingress)
kubectl exec -n swiggy-clone deployment/backend -- \
wget -qO- http://localhost:4000/health/live
## If this returns {"status":"alive"} → backend is fine, problem is Ingress config
## If this fails → problem is in the application itself

HPA not scaling:

Bash
## Step 1: Check if Metrics Server is running (required for HPA)
kubectl get deployment metrics-server -n kube-system
## If not found: kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
## Step 2: Check HPA events
kubectl describe hpa backend-hpa -n swiggy-clone
## "FailedGetScale" → RBAC permissions missing
## "unable to fetch metrics" → metrics-server not working
## "insufficient data to make a scaling decision" → wait 30 seconds
## Step 3: Verify metrics are available
kubectl top pods -n swiggy-clone
## If this fails, metrics-server is not working

What You Have Built

Stop and appreciate what you just built. This is not a toy project.

SQL
✅ A real three-tier application with business logic
✅ Containerised with best-practice Dockerfiles (multi-stage, non-root)
✅ Deployed to Kubernetes with proper resource management
✅ Database using StatefulSet with persistent storage
✅ Cache layer reducing database load
✅ Correct health checks (liveness vs readiness properly separated)
✅ Traffic routing via Ingress (one entry point, path-based routing)
✅ Auto-scaling from 2 to 10 pods based on real metrics
✅ Availability protection with PodDisruptionBudget
Full monitoring with Prometheus and Grafana
✅ GitOps delivery with ArgoCD

This is the architecture pattern used at early-stage Indian product companies. The same principles — maybe at 100x scale — are what Zerodha, Razorpay, and CRED run in production.

Every concept from the first seven roadmap steps comes together here:

  • Application Fundamentals → the Node.js API and health checks
  • Containerisation → the Dockerfiles and image building
  • Container Orchestration → the Kubernetes manifests
  • GitOps → ArgoCD managing deployments from Git
  • Observability → Prometheus, Grafana, and alerts

When you go to a Platform Engineering interview and they ask "Have you deployed a production-grade application to Kubernetes?" — you can answer yes. And you can walk them through exactly what you built and why every decision was made.

Next up: Capstone 2 — Build Production AWS Infrastructure with Terraform. That capstone provisions the actual cloud infrastructure this application would run on in a real company.

Videos & Guides

No external walkthroughs, video tutorials, or reference files attached to this blueprint yet.