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.
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. ---
Before writing a single line of code, understand the big picture. Here is what the finished application looks like: ``` 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. ---
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 ``` ---
### 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: ``` 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" ``` ```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; ``` ---
### 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. ```nginx # 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" ``` ---
### 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` ---
Most people learning Platform Engineering watch videos, read documentation, and follow tutorials one tool at a time. The...
Before writing a single line of code, understand the big picture. Here is what the finished application looks like: Thin...
You need these tools installed on your computer. Each one has a short explanation of what it is and why you need it: Doc...
What Are We Building in This Part? Before we touch Docker or Kubernetes, we write the actual application. This is import...
What Is a Container and Why Do We Need It? Right now the backend runs on your laptop. It works because your laptop has N...
What Is Kubernetes and Why Are We Moving to It? Docker Compose worked great locally. So why switch to Kubernetes? Docker...
Why Order Matters You cannot deploy the backend before the database exists. You cannot deploy the Ingress before the Ser...
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 ...
What Is GitOps and Why Is It Better Than kubectl apply? Right now you are running kubectl apply -f ... manually. This wo...
Every item in this checklist is something that real companies have been burned by in production. Check each one before c...
❌ Using Deployment instead of StatefulSet for PostgreSQL 💥 The PostgreSQL Deployment restarts onto a different node. Th...
Pod is in CrashLoopBackOff: Pod is stuck in Pending: Ingress returns 502 Bad Gateway: HPA not scaling: ---...
Stop and appreciate what you just built. This is not a toy project. This is the architecture pattern used at early-stage...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.