Explore the complete learning track from Linux fundamentals to advanced GitOps and Terraform. Packed with practical terminal sessions and real-world architectures.
Lesson 2 of 10ā¢35 min
Setting Up the Production Next.js + DB App
You will build a realistic Next.js 14 application with a PostgreSQL database. The goal is not a toy "Hello World" ā it is a production-quality app with proper connection pooling, a health check endpoint used by Kubernetes probes, a real CRUD API, and a frontend that shows the data.
This app is what you'll deploy automatically for the rest of the course.
Why Next.js 14 with the App Router?
The App Router (introduced in Next.js 13) is now the production standard. Here's what makes it the right choice for this capstone:
Feature
Benefit
Server Components
Data fetching happens on the server ā no API calls from the browser for initial page load
API Routes
/api/* routes run as serverless-style handlers ā no separate Express server needed
Built-in TypeScript
Type safety across both frontend and API code
output: 'standalone'
Creates a minimal Node.js server bundle, perfect for multi-stage Docker builds
Edge-compatible
Can run on edge runtimes if needed in future
Step 1: Initialize the Project
bash
# Create the project (--app enables the App Router)
npx create-next-app@latest capstone-app \
--typescript \
--eslint \
--tailwind \
--app \
--no-src-dir \
--import-alias "@/*"
cd capstone-app
What each flag does:
--typescript ā TypeScript from day one, no migration pain later
--eslint ā ESLint config ready, required for the CI pipeline lint step
--tailwind ā Utility-first CSS, great for quick UI work
--app ā Use the App Router (not the legacy pages/ directory)
--no-src-dir ā Keep files in the root app/ directory, not nested under src/
Step 2: Install Database Dependencies
bash
# PostgreSQL client for Node.js
npm install pg
npm install -D @types/pg
# Also install dotenv for local development
npm install -D dotenv-cli
The pg package is the official PostgreSQL client for Node.js. It supports connection pooling, parameterized queries (preventing SQL injection), and prepared statements.
Step 3: Configure next.config.ts for Standalone Output
This is critical for the Docker multi-stage build in Lesson 3. Without it, Next.js outputs a full node_modules/ directory that would bloat your image.
typescript
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone", // ā Generates a minimal server.js bundle
experimental: {
// Improve cold start times in containers
instrumentationHook: true,
},
};
export default nextConfig;
What output: "standalone" produces:
Project Structure
.next/
āāā standalone/
āāā server.js
ā Single entry point (replaces 'next start')
āāā node_modules/
ā Only the modules actually needed at runtime
āāā .next/
ā Compiled application code
Step 4: Project Directory Structure
Here's the complete file structure you'll end up with by the end of Lesson 7:
Project Structure
capstone-app/
āāā app/
ā Next.js App Router
ā āāā layout.tsx
ā Root layout (HTML shell)
ā āāā page.tsx
ā Home page (shows item list)
ā āāā globals.css
ā Tailwind base styles
ā āāā api/
ā āāā health/
ā ā āāā route.ts
ā GET /api/health (K8s liveness/readiness)
ā āāā items/
ā āāā route.ts
ā GET /api/items, POST /api/items
ā āāā [id]/
ā āāā route.ts
ā DELETE /api/items/:id
āāā lib/
ā āāā db.ts
ā PostgreSQL connection pool (singleton)
āāā components/
ā āāā ItemList.tsx
ā Client component for interactive UI
āāā Dockerfile
ā Multi-stage production Dockerfile
āāā .dockerignore
āāā .env.example
ā Safe to commit: template without real values
āāā .env.local
ā NOT committed: real dev credentials
āāā docker-compose.dev.yml
ā Local development stack
āāā k8s/
ā Kubernetes manifests
ā āāā namespace.yaml
ā āāā configmap.yaml
ā āāā secret.yaml
ā āāā deployment.yaml
ā āāā service.yaml
ā āāā ingress.yaml
āāā .github/
āāā workflows/
āāā ci.yml
ā CI: lint, test, scan (on every PR)
āāā cd.yml
ā CD: build, push, deploy (on merge to main)
Step 5: The Database Connection Pool
A connection pool reuses database connections instead of creating a new TCP connection for every query. This is essential for performance ā establishing a PostgreSQL connection takes ~5-50ms. Under load, creating a new connection per request would be catastrophic.
typescript
// lib/db.ts
import { Pool } from "pg";
// Using a singleton pattern to prevent multiple pool instances during
// Next.js hot-reload in development (which re-imports modules)
declare global {
// eslint-disable-next-line no-var
var _pgPool: Pool | undefined;
}
function createPool(): Pool {
return new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Maximum connections in the pool
min: 2, // Keep at least 2 connections open
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 5000, // Fail fast if DB is unreachable
// SSL configuration for production (many managed DBs require it)
ssl: process.env.NODE_ENV === "production"
? { rejectUnauthorized: true }
: false,
});
}
// Singleton: reuse pool in development (hot-reload), create fresh in production
const pool = global._pgPool ?? createPool();
if (process.env.NODE_ENV !== "production") {
global._pgPool = pool;
}
export default pool;
Connection Pool Sizing
With 3 Kubernetes replicas, each having max: 10, you could open up to 30 connections simultaneously. Make sure your PostgreSQL max_connections setting (default: 100) is higher than replicas Ć pool.max. In Lesson 7, you'll configure a PgBouncer or use max: 5 per replica.
Step 6: Health Check API Route
Kubernetes uses the /api/health endpoint for liveness and readiness probes:
Liveness probe: "Is this pod alive?" ā if it fails 3 times, K8s restarts the pod
Readiness probe: "Is this pod ready to receive traffic?" ā if it fails, K8s removes it from the Service endpoints
Your health check should test actual system readiness ā not just "is the Node process running":
# .env.example ā COMMIT THIS FILE (no real secrets)
# Copy to .env.local and fill in real values
DATABASE_URL=postgres://appuser:changeme@localhost:5432/capstone
APP_VERSION=dev
NODE_ENV=development
bash
# .env.local ā DO NOT COMMIT (add to .gitignore)
DATABASE_URL=postgres://appuser:apppass@localhost:5432/capstone
APP_VERSION=dev
bash
# Ensure .env.local is gitignored (create-next-app does this automatically)
cat .gitignore | grep env
# .env*.local ā should be here
Step 10: Local Development with Docker Compose
Run the full stack locally with one command:
yaml
# docker-compose.dev.yml
services:
app:
build:
context: .
target: builder # Use the builder stage, not the slim runner (easier for dev)
command: npm run dev
ports:
- "3000:3000"
volumes:
- .:/app # Mount source for hot-reload
- /app/node_modules # Keep container's node_modules intact
- /app/.next # Keep compiled output in container
environment:
DATABASE_URL: postgres://appuser:apppass@db:5432/capstone
NODE_ENV: development
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: capstone
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d capstone"]
interval: 5s
timeout: 5s
retries: 10
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
bash
# Start the dev stack
docker compose -f docker-compose.dev.yml up -d
# Watch logs
docker compose -f docker-compose.dev.yml logs -f app
# Test the health endpoint
curl http://localhost:3000/api/health | jq
# { "status": "ok", "db": { "status": "connected", "latencyMs": 2 } }
# Add an item
curl -X POST http://localhost:3000/api/items \
-H "Content-Type: application/json" \
-d '{"name": "My first item"}'
# List items
curl http://localhost:3000/api/items | jq
# Stop everything
docker compose -f docker-compose.dev.yml down
Step 11: Add Unit Tests
The CI pipeline (Lesson 4) will run these tests automatically on every pull request.
# Run tests locally
npm test
# Run lint
npm run lint
# Type-check (no compilation output, just type errors)
npm run type-check
Step 12: Initialize the Git Repository and Push to GitHub
bash
# Initialize git (create-next-app may have already done this)
git init
git add .
git commit -m "feat: initial Next.js app with PostgreSQL and health check"
# Create a new repository on GitHub (via CLI or browser)
# Then add the remote
git remote add origin git@github.com:YOUR_USERNAME/capstone-app.git
git push -u origin main
# Check if the postgres container is healthy
docker compose -f docker-compose.dev.yml ps
# LOOK FOR: Status = healthy (not starting)
# Check postgres logs
docker compose -f docker-compose.dev.yml logs db
# Connect manually to verify
docker compose -f docker-compose.dev.yml exec db \
psql -U appuser -d capstone -c "SELECT 1"
"Module not found: @/lib/db"
bash
# Ensure your tsconfig.json has the path alias
cat tsconfig.json | grep paths
# Should see: "@/*": ["./*"]
# Ensure next.config.ts has the same alias
# create-next-app adds this automatically with --import-alias "@/*"
Hot-reload not working in Docker
bash
# The volume mount approach in docker-compose.dev.yml should handle this.
# If not, ensure WATCHPACK_POLLING=true is set:
environment:
WATCHPACK_POLLING: "true"
Summary
You now have a production-quality application with:
A database connection pool with proper timeout configuration
A /api/health endpoint that tests real database connectivity (used by Kubernetes probes in Lesson 7)
A full CRUD API with input validation and parameterized queries (no SQL injection)
An interactive frontend component
Unit tests for the health endpoint
A local Docker Compose dev environment
In the next lesson, you will write the production-hardened multi-stage Dockerfile that packages this application into a minimal, secure container image.