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
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:
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 6379Think 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:
- Presentation layer (frontend — what users see)
- Application layer (API — business logic)
- 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.
## Verify everything is installeddocker --versionkubectl version --clientnode --versiongit --version ## Start minikube if using locallyminikube start --memory=4096 --cpus=2 ## Verify kubectl can reach your clusterkubectl get nodes## You should see a node in "Ready" statusPart 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
## Create the main project foldermkdir swiggy-clone && cd swiggy-clone ## Initialise git immediately — good habitgit init ## Create the folder structuremkdir -p backend/src/routesmkdir -p frontend/srcmkdir -p k8s/databasemkdir -p k8s/cachemkdir -p k8s/backendmkdir -p k8s/frontendmkdir -p k8s/ingressmkdir -p k8s/monitoringmkdir -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 pipelineSeparating 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
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 productionnpm 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.
// backend/src/db.js const { Pool } = require('pg'); // Pool reads connection details from environment variables// We never hardcode passwords here — they come from Kubernetes Secretsconst 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 laterpool.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
// 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.
// 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
// 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
// backend/src/index.js const express = require('express');const cors = require('cors');const helmet = require('helmet');const morgan = require('morgan'); // Import our route handlersconst 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 APIapp.use(morgan('combined')); // logs every request: method, path, status, timeapp.use(express.json()); // parses JSON request bodies // ─── Routes ───────────────────────────────────────────────────app.use('/api/orders', ordersRouter); // /api/orders → ordersRouterapp.use('/health', healthRouter); // /health/live and /health/ready // ─── Error Handlers ───────────────────────────────────────────// Catch requests to routes that do not existapp.use((req, res) => { res.status(404).json({ error: `Route ${req.path} not found` });}); // Catch unhandled errors from any routeapp.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}`);});// 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.
-- 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 timesCREATE 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 rowsCREATE 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 inspiredINSERT 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 exists1.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.
cd ../frontend ## create-react-app creates a complete React project structurenpx create-react-app . --template minimal ## axios is a library that makes HTTP requests simpler## We use it to call our Node.js APInpm install axios echo "✅ Frontend created"// 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:4000const 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:
- Builder stage — installs everything needed to build the app
- 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
# 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 depsWORKDIR /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 codeCOPY 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 runnerWORKDIR /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 containerRUN 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 filesCOPY --from=deps --chown=nodeuser:nodejs /app/node_modules ./node_modulesCOPY --chown=nodeuser:nodejs src/ ./src/ # Switch to the non-root userUSER nodeuser # Tell Docker which port this container uses# This is documentation — it does not actually open the portEXPOSE 4000 # Health check built into the image# Docker (and Kubernetes) can use this to know if the container is healthyHEALTHCHECK --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 startsCMD ["node", "src/index.js"]2.2 Frontend Dockerfile and Nginx Config
The frontend needs two things:
- Build the React app into static HTML, CSS, and JavaScript files
- 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.
# 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; }}# frontend/Dockerfile # ── Stage 1: Build the React app ──────────────────────────────FROM node:18-alpine AS builderWORKDIR /app COPY package*.json ./RUN npm ci COPY . . # npm run build compiles React into static files# Output goes to /app/build directoryRUN npm run build # ── Stage 2: Serve with Nginx ─────────────────────────────────FROM nginx:alpine AS runner # Copy the built React files into the directory Nginx serves fromCOPY --from=builder /app/build /usr/share/nginx/html # Replace the default Nginx config with our custom oneCOPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80CMD ["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.
# docker-compose.yml — LOCAL TESTING ONLY, not for productionversion: '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:## Build and start all servicesdocker-compose up --build ## Wait about 30 seconds for everything to start, then test: ## Test backend healthcurl http://localhost:4000/health/ready## Expected: {"status":"ready","checks":{"database":"ok","cache":"ok"}} ## Test APIcurl http://localhost:4000/api/orders## Expected: {"source":"database","data":[...5 sample orders...]} ## Create a new ordercurl -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 cachecurl 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 everythingdocker-compose downecho "✅ 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.
## Set your registry details## Replace 'your-dockerhub-username' with your actual Docker Hub usernameexport REGISTRY=your-dockerhub-usernameexport VERSION=v1.0.0 ## Login to Docker Hub firstdocker login ## Build and tag images## The tag format is: registry/image-name:versiondocker build -t ${REGISTRY}/swiggy-backend:${VERSION} ./backenddocker build -t ${REGISTRY}/swiggy-backend:latest ./backenddocker build -t ${REGISTRY}/swiggy-frontend:${VERSION} ./frontenddocker build -t ${REGISTRY}/swiggy-frontend:latest ./frontend ## Push to the registrydocker push ${REGISTRY}/swiggy-backend:${VERSION}docker push ${REGISTRY}/swiggy-backend:latestdocker 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
# k8s/namespace.yaml apiVersion: v1kind: Namespacemetadata: 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: productionkubectl apply -f k8s/namespace.yaml ## Verify it was createdkubectl get namespaces | grep swiggy-clone3.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.
# 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 ManagerapiVersion: v1kind: Secretmetadata: name: postgres-secret namespace: swiggy-clonetype: OpaquestringData: # 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: v1kind: PersistentVolumeClaimmetadata: name: postgres-pvc namespace: swiggy-clonespec: 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 podapiVersion: v1kind: ConfigMapmetadata: name: postgres-init namespace: swiggy-clonedata: 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/v1kind: StatefulSetmetadata: name: postgres namespace: swiggy-clonespec: 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.localapiVersion: v1kind: Servicemetadata: name: postgres namespace: swiggy-clonespec: 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 DNSapiVersion: v1kind: Servicemetadata: name: postgres-service namespace: swiggy-clonespec: selector: app: postgres ports: - port: 5432 targetPort: 54323.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.
# k8s/cache/redis.yaml apiVersion: apps/v1kind: Deploymentmetadata: name: redis namespace: swiggy-clonespec: 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: v1kind: Servicemetadata: name: redis-service namespace: swiggy-clonespec: selector: app: redis ports: - port: 6379 targetPort: 63793.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.
# k8s/backend/deployment.yaml apiVersion: apps/v1kind: Deploymentmetadata: name: backend namespace: swiggy-clone labels: app: backendspec: 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 sameapiVersion: v1kind: Servicemetadata: name: backend-service namespace: swiggy-clonespec: selector: app: backend # route traffic to pods with this label ports: - name: http port: 4000 targetPort: 40003.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.
# k8s/frontend/deployment.yaml apiVersion: apps/v1kind: Deploymentmetadata: name: frontend namespace: swiggy-clonespec: 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: v1kind: Servicemetadata: name: frontend-service namespace: swiggy-clonespec: selector: app: frontend ports: - port: 80 targetPort: 803.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.
## First install the Nginx Ingress Controller## This creates the actual load balancer that handles incoming traffickubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml ## Wait for it to be readykubectl wait --for=condition=Ready pod \ -l app.kubernetes.io/component=controller \ -n ingress-nginx \ --timeout=120s echo "✅ Ingress Controller ready"# k8s/ingress/ingress.yaml apiVersion: networking.k8s.io/v1kind: Ingressmetadata: 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## 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/hosts3.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).
# 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/v2kind: HorizontalPodAutoscalermetadata: name: backend-hpa namespace: swiggy-clonespec: # 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: 603.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.
# k8s/backend/pdb.yaml apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: backend-pdb namespace: swiggy-clonespec: # 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: backend3.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.
# k8s/network-policies.yaml # Rule 1: Default deny — block ALL ingress and egress by default# Apply this first, then add specific allow rules belowapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-all namespace: swiggy-clonespec: 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 53apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-dns namespace: swiggy-clonespec: 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 podsapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-ingress-to-frontend namespace: swiggy-clonespec: 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 APIapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-ingress-to-backend namespace: swiggy-clonespec: 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 directlyapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-backend-to-postgres namespace: swiggy-clonespec: 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 frontendapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-backend-to-redis namespace: swiggy-clonespec: 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/v1kind: NetworkPolicymetadata: name: allow-backend-egress namespace: swiggy-clonespec: 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 podsapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-prometheus-scraping namespace: swiggy-clonespec: podSelector: matchLabels: app: backend # prometheus scrapes the backend policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring ports: - port: 4000## Apply the network policieskubectl apply -f k8s/network-policies.yaml ## Verify policies are in placekubectl get networkpolicies -n swiggy-clone ## Test that isolation works correctly:## Frontend pod should NOT be able to reach PostgreSQL directlykubectl 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 PostgreSQLkubectl 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 endcurl http://swiggy-clone.local/api/orders## Should return orders — network policies allow the correct flowSecurityNetworkPolicies 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.
## Step 1: Create the namespacekubectl apply -f k8s/namespace.yaml ## Step 2: Create the data layer (database and cache)kubectl apply -f k8s/database/postgres.yamlkubectl 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 layerkubectl apply -f k8s/backend/deployment.yamlkubectl apply -f k8s/frontend/deployment.yaml ## Step 5: Set up scaling and availabilitykubectl apply -f k8s/backend/hpa.yamlkubectl apply -f k8s/backend/pdb.yaml ## Step 6: Set up traffic routingkubectl apply -f k8s/ingress/ingress.yaml echo "✅ All manifests applied"Verify Everything is Working
## Check all pods are Running with READY=1/1## READY=0/1 means the readiness probe is failingkubectl 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 createdkubectl get services -n swiggy-clone ## Check that the Ingress has an address assignedkubectl 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 Ingresscurl http://swiggy-clone.local/api/orders ## Open in browserecho "Open http://swiggy-clone.local in your browser"Test Auto-Scaling
## 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 heychmod +x hey && sudo mv hey /usr/local/bin/ ## In one terminal: watch HPA react to loadkubectl get hpa -n swiggy-clone --watch ## In another terminal: generate loadhey -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 2Part 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
## Add the Prometheus community Helm chart repositoryhelm repo add prometheus-community \ https://prometheus-community.github.io/helm-chartshelm 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.
# k8s/monitoring/servicemonitor.yaml apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: backend-monitor namespace: monitoring labels: # This label must match what the Prometheus installation looks for release: monitoringspec: 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 secondskubectl apply -f k8s/monitoring/servicemonitor.yaml5.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.
# k8s/monitoring/alerts.yaml apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: swiggy-clone-alerts namespace: monitoring labels: release: monitoringspec: 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"kubectl apply -f k8s/monitoring/alerts.yaml5.4 Access Grafana
## Port-forward Grafana to your local machinekubectl 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 dashboardsPart 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 editand changes something directly in the cluster, there is no record of it and the nextkubectl applyoverwrites 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
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 UIkubectl port-forward svc/argocd-server -n argocd 8080:443## Open https://localhost:8080## Username: admin Password: from above6.2 Push Manifests to GitHub
## 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."
# argocd-app.yaml apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: 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.iospec: 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 existkubectl apply -f argocd-app.yaml ## Watch ArgoCD synckubectl 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.yamlgit commit -m "scale: increase backend replicas to 3"git push ## Watch ArgoCD automatically detect the change and apply itkubectl get pods -n swiggy-clone --watch## You should see a third backend pod appear within ~3 minutesPart 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.
## ─── 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 2curl -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:
## 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=postgreskubectl 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-clonePod is stuck in Pending:
## Pending means Kubernetes cannot schedule the pod onto any node## Step 1: Check whykubectl 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-labelsIngress returns 502 Bad Gateway:
## 502 means Nginx received the request but could not reach the backend## Step 1: Check Ingress controller logskubectl logs -n ingress-nginx \ deployment/ingress-nginx-controller --tail=50 | grep "error\|fail" ## Step 2: Check if backend pods are Readykubectl 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 itselfHPA not scaling:
## 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 eventskubectl 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 availablekubectl top pods -n swiggy-clone## If this fails, metrics-server is not workingWhat You Have Built
Stop and appreciate what you just built. This is not a toy project.
✅ 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 ArgoCDThis 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.