Warming up the neural circuits...
By the end of this chapter you will:
Building a feature is half the battle. Shipping it — reliably, repeatably, with zero-downtime and automated rollback — is the other half. This project ties together every DevOps concept from L6 into a single pipeline that takes code from
git pushto production traffic. When you finish this project, you'll have a deployment pipeline that would make a Fortune 500 DevOps engineer nod in approval.
You've already built two substantial backend projects in this course — P1 ( ) and P2 (Real-time Chat Backend). Right now, they run on your laptop. This project takes one of them (your choice, or both if you're ambitious) and deploys it to a real server, with real SSL, real , and real monitoring.
The pipeline you build in this project is the same pattern used by startups shipping to their first 10,000 users. It's not over-engineered — it's exactly what you need and nothing you don't.
docker-compose.ymlmain → test → build → push image → deploy to VPS/health endpointgit push origin main triggers the full pipelinehttps://api.yourdomain.comcurl https://api.yourdomain.com/health returns {"status":"ok"}┌─────────────────────────────────────────────────────────────────────┐
│ GITHUB ACTIONS (CI/CD) │
│ │
│ git push → main │
│ │ │
│ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Run │───▶│ Build │───▶│ Push to │───▶│ SSH to VPS │ │
│ │ Tests │ │ Docker │ │ GHCR │ │ → pull image │ │
│ │ │ │ Image │ │ (GitHub │ │ → docker │ │
│ │ │ │ │ │ Container│ │ compose up │ │
│ │ │ │ │ │ Registry)│ │ → health check│ │
│ └─────────┘ └──────────┘ └──────────┘ └───────┬───────┘ │
│ │ │
│ Health check fails? │
│ → docker compose up │
│ previous image │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ VPS (Hetzner / DO) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ NGINX (Reverse Proxy) │ │
│ │ Port 443 (HTTPS) ───────▶ Port 80 (HTTP redirect) │ │
A production Dockerfile does three things: builds efficiently (caching layers), produces a small image (multi-stage), and runs securely (non-root user).
# === Dockerfile ===
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files first (layer caching — only reinstall on package.json changes)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Production
FROM
node_modules
.git
.env
.env.*
dist
coverage
*.md
.github
.vscode# Build the image
docker build -t myapp:latest .
# Run it locally
docker run -p 3000:3000 --env-file .env myapp:latest
# Check the health endpoint
curl http://localhost:3000/health
# Check image size — should be < 200MB for a Node app
docker images myappnode:22-alpine (~50MB) vs node:22 (~350MB). The alpine variant uses musl libc instead of glibc — it's smaller and has a smaller attack surface. The tradeoff: some native modules (bcrypt, sharp) need compilation against musl. Test your app on alpine early to catch compatibility issues.
# === docker-compose.yml ===
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: myapp
restart: unless-stopped
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://appuser:${DB_PASSWORD}@postgres:5432/myapp
# === .env (NEVER commit this file) ===
DB_PASSWORD=generate-a-strong-random-password-here
JWT_SECRET=another-strong-random-secret
# === .env.example (committed to git, shows required vars) ===
DB_PASSWORD=your-database-password
JWT_SECRET=your-jwt-secret# === nginx/conf.d/app.conf ===
upstream app_backend {
server app:3000;
keepalive 32;
}
server {
listen 80;
server_name api.yourdomain.com;
# Let's Encrypt ACME challenge (for certbot)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect everything else to HTTPS
location / {
return 301
# === .github/workflows/deploy.yml ===
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # Allow manual trigger
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services
GHCR (ghcr.io) is free for public repos, includes anonymous pulls from GitHub Actions, and keeps your images close to your code. Docker Hub has rate limits (100 pulls/6 hours for anonymous users). For private repos, GHCR uses the same GitHub token you already have — no extra credentials to manage.
#!/bin/bash
# === setup-vps.sh ===
# Run once on a fresh VPS (Ubuntu 24.04 LTS)
set -e # Exit on any error
echo "=== Updating system ==="
sudo apt update && sudo apt upgrade -y
echo "=== Installing Docker ==="
curl -fsSL https://get.docker.com |
# Initial certificate issuance (run once, manually or via CI)
# Stop nginx temporarily (certbot needs port 80)
cd /opt/myapp
docker compose -f docker-compose.prod.yml stop nginx
# Get certificate (standalone mode — certbot runs its own web server)
sudo certbot certonly --standalone -d api.yourdomain.com
# Restart nginx with the new certificate
docker compose -f docker-compose.prod.yml up -d nginx
# === /opt/myapp/backup-db.sh ===
#!/bin/bash
BACKUP_DIR=/opt/myapp/backups
mkdir -p $BACKUP_DIR
# Dump the database from the running container
docker exec myapp-db pg_dump -U appuser myapp | gzip > "$BACKUP_DIR/myapp-$(date +%Y%m%d-%H%M).sql.gz"
#
# On your local machine:
# 1. Build and tag the image
docker build -t ghcr.io/yourusername/yourapp:v1.0.0 .
# 2. Push to GHCR
echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin
docker push ghcr.io/yourusername/yourapp:v1.0.0
# 3. Copy config files to VPS
scp docker-compose.prod.yml deploy@your-vps-ip:/opt/myapp/
scp
# After CI/CD is configured:
git add .
git commit -m "feat: add user profile endpoint"
git push origin main
# Watch the deploy in GitHub Actions:
# https://github.com/yourusername/yourapp/actions
# Verify in production:
curl https://api.yourdomain.com/health
# {"status":"ok","version":"abc123","uptime":42}# SSH to VPS
ssh deploy@your-vps-ip
cd /opt/myapp
# List recent deploys
tail -5 deploy-history.txt
# Rollback to previous SHA
PREV_SHA=$(tail -2 deploy-history.txt | head -1)
IMAGE_TAG=$PREV_SHA docker compose -f docker-compose.prod.yml up
# One-command install — gives you beautiful dashboards for CPU, memory, disk,
# network, per-process metrics, and dozens of pre-configured alerts
wget -O /tmp/netdata-kickstart.sh https://get.netdata.cloud/kickstart.sh
sh /tmp/netdata-kickstart.sh --stable-channel --disable-telemetry
# Access dashboard at: http://your-vps-ip:19999
# Secure it with Nginx as a reverse proxy:
# location /netdata/ {
# proxy_pass http://localhost:19999/;
# auth_basic "Netdata";
#
# Add to docker-compose.prod.yml:
prometheus:
image: prom/prometheus
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
restart: unless-stopped
grafana:
image: grafana/grafana
ports:
- '3001:3000'
volumes:
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
Committing .env to git | Secrets in version control are permanent. Anyone with repo access (now or in the future) can read them | Add .env to .gitignore. Use .env.example for documentation. Use GitHub Secrets for CI/CD variables |
| Running containers as root | A container escape vulnerability gives the attacker root on the host | Use USER appuser in Dockerfile. Never use privileged: true in compose unless absolutely essential |
| Not pinning image versions in Dockerfile | FROM node:22 gets a different image every build. Your production breaks when Node 22.1 introduces a breaking change | Use specific tags: FROM node:22.11.0-alpine or at minimum FROM node:22-alpine |
| Skipping the health check in CI/CD deploy step | A deploy that starts but doesn't work looks successful. You discover the outage when users complain | Always run a health check after deploy. If it fails, rollback automatically |
| Hardcoding the VPS IP in CI/CD | IPs change. When you migrate servers, you need to update the workflow file and commit | Store VPS_HOST as a GitHub Secret. Update in one place |
| Not testing the backup restoration | Backups that haven't been tested are not backups — they're hopes. You discover corruption during an actual disaster |
Q: Walk me through your deployment pipeline from git push to production traffic.
A: When code is pushed to main, GitHub Actions triggers the workflow. First, tests run against a real Postgres service container. If tests pass, the Docker image is built using layer caching (from GitHub Actions ) and pushed to GitHub Container Registry with two tags: latest and the commit SHA. Then, the deploy job SSHs into the VPS, pulls the new image, runs docker compose up -d with the new image tag, and runs a health check loop (30 attempts, 5 seconds apart). If health check passes, deploy is complete — users are now hitting the new version. If health check fails after 30 attempts, the script automatically rolls back to the previous image SHA (stored in a deploy history file). The entire pipeline takes 3-5 minutes from push to verified deployment.
Q: Why use a multi-stage Dockerfile instead of a single stage? A: A multi-stage build separates the build environment from the runtime environment. Stage 1 (builder) has the full Node.js toolchain, dev dependencies, compiler, and source code — it produces the compiled output. Stage 2 (runner) copies only the compiled output and production dependencies. The result: a production image that's 50-70% smaller (no devDependencies, no TypeScript, no build tools). This means faster pulls, less disk usage, and a smaller attack surface (no compiler to exploit). The builder stage is discarded after the build — it doesn't end up in the final image.
Q: How do you handle database migrations in this CI/CD pipeline?
A: Database migrations should run BEFORE the new application code starts, but AFTER the backup is taken. The safest approach: (1) CI/CD pipeline takes a pre-deploy database snapshot (pg_dump). (2) Run migrations (npm run migrate:up or npx prisma migrate deploy) against the production database. Migrations should be backward-compatible (add columns, don't rename; add tables, don't drop). (3) Deploy the new application code. (4) If health check fails, the rollback only reverts the application code — the database migration remains (since it's backward-compatible, the old code still works with the new schema). For destructive migrations (column drops, renames): use a multi-deploy strategy — deploy schema changes in one PR, deploy the code that uses them in the next PR, never both together.
This project transforms your locally-running backend into a production-deployed, SSL-secured, CI/CD-automated application. Phase 1 creates a multi-stage Dockerfile that builds efficiently and runs securely. Phase 2 orchestrates the full stack (app + Postgres + Redis + Nginx) with Docker Compose, including health checks at every service. Phase 3 builds the CI/CD pipeline in GitHub Actions that tests, builds, pushes, deploys, and auto-rolls-back on failure. Phase 4 provisions and hardens a VPS — the same checklist you'll reuse for every server you ever set up. Phase 5 walks through the first deploy and subsequent automatic deploys. Phase 6 adds monitoring so you know your app is healthy before users tell you it's not. The result: git push origin main → 3-5 minutes → changes live on https://api.yourdomain.com. This is the deployment pattern that takes you from "it works on my machine" to "it works in production."
docker compose up -d orchestrates app + DB + Redis + Nginx. Health checks (depends_on: condition: service_healthy) ensure correct start order.latest + commit SHA for rollback tracking./health endpoint.docker compose up -d with previous image SHA..env in .gitignore, .env.example committed, production secrets in GitHub Secrets, never in code.| Quarterly: spin up a fresh Postgres container, restore the latest backup, run your test suite against it |
| Exposing Docker socket to containers | Mounting /var/run/docker.sock gives the container full control over Docker on the host — essentially root access | Never mount the Docker socket unless you have a very specific, security-reviewed reason |