Back to blog

Build a URL Shortener: Deployment & Production

typescriptdockerdevopscicdmonitoring
Build a URL Shortener: Deployment & Production

Chúng ta đã build xong full-stack URL shortener — API, database, cache, auth, frontend và tests. Mọi thứ chạy ngon lành trên laptop. Nhưng software chỉ chạy được trên localhost thì chưa phải software — đó chỉ là prototype.

Bài cuối cùng này đưa project từ development lên production. Chúng ta sẽ containerize mọi thứ bằng Docker, tự động hóa deployment bằng GitHub Actions, thêm monitoring bằng Prometheus và Grafana, và hardening application cho real-world traffic.

Thời gian: 2–3 giờ
Prerequisites: Phase 8: Testing Strategy

Những gì chúng ta sẽ build trong bài này:
✅ Multi-stage Docker builds cho API và frontend
✅ Docker Compose orchestrating toàn bộ stack
✅ GitHub Actions CI/CD pipeline (lint, test, build, deploy)
✅ Health check endpoints chi tiết với dependency status
✅ Prometheus metrics và Grafana dashboards
✅ Structured JSON logging với pino
✅ Production hardening (Helmet, CORS, compression)
✅ Nginx reverse proxy configuration


Tổng Quan Kiến Trúc Production

Đây là hình dung deployment production của chúng ta:

Mỗi component chạy trong Docker container riêng. Hãy build từng layer một.


Multi-Stage Docker Build: API Server

Multi-stage builds giữ production images nhỏ gọn bằng cách tách build environment ra khỏi runtime environment.

API Dockerfile

# Dockerfile (API)
 
# ── Stage 1: Build ──────────────────────────────
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Copy dependency files trước (layer caching)
COPY package.json package-lock.json ./
COPY prisma ./prisma/
 
# Install tất cả dependencies (bao gồm devDependencies để build)
RUN npm ci
 
# Generate Prisma client
RUN npx prisma generate
 
# Copy source code
COPY tsconfig.json ./
COPY src ./src/
 
# Build TypeScript
RUN npm run build
 
# ── Stage 2: Production ────────────────────────
FROM node:20-alpine AS production
 
WORKDIR /app
 
# Tạo non-root user cho security
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
 
# Copy dependency files
COPY package.json package-lock.json ./
COPY prisma ./prisma/
 
# Install production dependencies only
RUN npm ci --only=production && \
    npx prisma generate && \
    npm cache clean --force
 
# Copy built files từ builder stage
COPY --from=builder /app/dist ./dist
 
# Set ownership
RUN chown -R appuser:appgroup /app
 
USER appuser
 
# Expose port
EXPOSE 3000
 
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
 
# Start server
CMD ["node", "dist/index.js"]

Tại sao multi-stage?

  • Builder stage có TypeScript compiler, dev dependencies — ~400MB
  • Production stage chỉ có runtime code — ~150MB
  • Giảm 60% kích thước image

Tại sao non-root user? Nếu attacker exploit được vulnerability trong app, họ chỉ có quyền hạn chế thay vì root privileges trong container.

Tại sao npm ci thay vì npm install? npm ci install chính xác từ lock file, đảm bảo reproducible builds. Nó cũng xóa node_modules trước, tránh stale dependencies.

.dockerignore

# .dockerignore
node_modules
dist
.env
.env.local
.git
.gitignore
*.md
tests
coverage
.vscode
docker-compose*.yml

File này ngăn việc copy files không cần thiết vào Docker build context, tăng tốc builds đáng kể.


Docker Build: Frontend

React frontend có Docker image riêng, serve bởi Nginx cho production performance.

# frontend/Dockerfile
 
# ── Stage 1: Build ──────────────────────────────
FROM node:20-alpine AS builder
 
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
COPY . .
 
# Build-time environment variables
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
 
RUN npm run build
 
# ── Stage 2: Serve với Nginx ───────────────────
FROM nginx:alpine AS production
 
# Xóa default nginx config
RUN rm /etc/nginx/conf.d/default.conf
 
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/
 
# Copy built React app
COPY --from=builder /app/dist /usr/share/nginx/html
 
# Expose port
EXPOSE 80
 
CMD ["nginx", "-g", "daemon off;"]

Frontend Nginx Config

# frontend/nginx.conf
 
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;
 
    # Enable gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;
    gzip_min_length 1000;
 
    # Cache static assets aggressively
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
 
    # SPA fallback — tất cả routes serve index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Docker Compose: Full Stack

Giờ chúng ta orchestrate mọi thứ bằng Docker Compose:

# docker-compose.yml
 
services:
  # ── PostgreSQL ─────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: url-shortener-db
    environment:
      POSTGRES_DB: url_shortener
      POSTGRES_USER: ${DB_USER:-postgres}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?Database password required}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d url_shortener"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
 
  # ── Redis ──────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: url-shortener-redis
    command: redis-server --requirepass ${REDIS_PASSWORD:?Redis password required}
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
 
  # ── API Server ─────────────────────────────────
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: url-shortener-api
    environment:
      NODE_ENV: production
      PORT: 3000
      DATABASE_URL: postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@postgres:5432/url_shortener
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
      JWT_SECRET: ${JWT_SECRET:?JWT secret required}
      CORS_ORIGIN: ${CORS_ORIGIN:-https://yourdomain.com}
      BASE_URL: ${BASE_URL:-https://yourdomain.com}
    ports:
      - "3000:3000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
 
  # ── Frontend ───────────────────────────────────
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        VITE_API_URL: /api
    container_name: url-shortener-frontend
    ports:
      - "8080:80"
    restart: unless-stopped
 
  # ── Nginx Reverse Proxy ────────────────────────
  nginx:
    image: nginx:alpine
    container_name: url-shortener-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - api
      - frontend
    restart: unless-stopped
 
  # ── Prometheus ─────────────────────────────────
  prometheus:
    image: prom/prometheus:latest
    container_name: url-shortener-prometheus
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    restart: unless-stopped
 
  # ── Grafana ────────────────────────────────────
  grafana:
    image: grafana/grafana:latest
    container_name: url-shortener-grafana
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3001:3000"
    depends_on:
      - prometheus
    restart: unless-stopped
 
volumes:
  postgres_data:
  redis_data:
  prometheus_data:
  grafana_data:

Khởi Chạy Stack

# Tạo .env file cho production secrets
cat > .env << 'EOF'
DB_USER=postgres
DB_PASSWORD=your-secure-db-password
REDIS_PASSWORD=your-secure-redis-password
JWT_SECRET=your-secure-jwt-secret-at-least-32-chars
CORS_ORIGIN=https://yourdomain.com
BASE_URL=https://yourdomain.com
GRAFANA_PASSWORD=your-grafana-password
EOF
 
# Start mọi thứ
docker compose up -d --build
 
# Chạy database migrations
docker compose exec api npx prisma migrate deploy
 
# Kiểm tra tất cả services healthy
docker compose ps

Kết quả mong đợi:

NAME                       STATUS                    PORTS
url-shortener-api          Up (healthy)              0.0.0.0:3000->3000/tcp
url-shortener-db           Up (healthy)              0.0.0.0:5432->5432/tcp
url-shortener-redis        Up (healthy)              0.0.0.0:6379->6379/tcp
url-shortener-frontend     Up                        0.0.0.0:8080->80/tcp
url-shortener-nginx        Up                        0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
url-shortener-prometheus   Up                        0.0.0.0:9090->9090/tcp
url-shortener-grafana      Up                        0.0.0.0:3001->3000/tcp

Quản Lý Environment Variables

Đừng bao giờ hardcode secrets. Đây là pattern sạch để quản lý environment variables giữa các environments:

.env.example

# .env.example — commit file này vào git (không chứa giá trị thật!)
 
# Database
DB_USER=postgres
DB_PASSWORD=change-me
DATABASE_URL=postgresql://postgres:change-me@localhost:5432/url_shortener
 
# Redis
REDIS_PASSWORD=change-me
REDIS_URL=redis://:change-me@localhost:6379
 
# Auth
JWT_SECRET=change-me-to-at-least-32-characters
 
# App
NODE_ENV=production
PORT=3000
BASE_URL=https://yourdomain.com
CORS_ORIGIN=https://yourdomain.com
 
# Monitoring
GRAFANA_PASSWORD=admin

Validate Environment Khi Startup

// src/config/env.ts
 
import { z } from 'zod';
 
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  BASE_URL: z.string().url(),
  CORS_ORIGIN: z.string(),
});
 
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('❌ Invalid environment variables:');
  console.error(parsed.error.format());
  process.exit(1);
}
 
export const env = parsed.data;

Fail fast. Nếu environment variable bắt buộc bị thiếu, server từ chối start với error message rõ ràng. Điều này tốt hơn nhiều so với phát hiện undefined lúc 3 giờ sáng trên production.


Health Check Endpoints

Một endpoint /health đơn giản không đủ cho production. Chúng ta cần biết dependencies có healthy không nữa.

// src/routes/healthRoutes.ts
 
import { Router, Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { redisClient } from '../config/redis';
 
const router = Router();
const prisma = new PrismaClient();
 
interface HealthStatus {
  status: 'healthy' | 'degraded' | 'unhealthy';
  timestamp: string;
  uptime: number;
  version: string;
  checks: {
    database: ComponentHealth;
    redis: ComponentHealth;
  };
}
 
interface ComponentHealth {
  status: 'up' | 'down';
  latency?: number;
  error?: string;
}
 
async function checkDatabase(): Promise<ComponentHealth> {
  const start = Date.now();
  try {
    await prisma.$queryRaw`SELECT 1`;
    return { status: 'up', latency: Date.now() - start };
  } catch (error) {
    return {
      status: 'down',
      latency: Date.now() - start,
      error: error instanceof Error ? error.message : 'Unknown error',
    };
  }
}
 
async function checkRedis(): Promise<ComponentHealth> {
  const start = Date.now();
  try {
    await redisClient.ping();
    return { status: 'up', latency: Date.now() - start };
  } catch (error) {
    return {
      status: 'down',
      latency: Date.now() - start,
      error: error instanceof Error ? error.message : 'Unknown error',
    };
  }
}
 
// Simple liveness probe — process có đang chạy không?
router.get('/health/live', (_req: Request, res: Response) => {
  res.json({ status: 'ok' });
});
 
// Readiness probe — instance này có thể serve traffic không?
router.get('/health/ready', async (_req: Request, res: Response) => {
  const [database, redis] = await Promise.all([
    checkDatabase(),
    checkRedis(),
  ]);
 
  const allUp = database.status === 'up' && redis.status === 'up';
 
  const health: HealthStatus = {
    status: allUp ? 'healthy' : database.status === 'down' ? 'unhealthy' : 'degraded',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    version: process.env.npm_package_version || '1.0.0',
    checks: { database, redis },
  };
 
  res.status(allUp ? 200 : 503).json(health);
});
 
export default router;

Hai endpoints, hai mục đích:

  • /health/live — Kubernetes liveness probe. Nếu fail, restart container.
  • /health/ready — Kubernetes readiness probe. Nếu fail, ngừng route traffic đến instance này nhưng không restart.

Response ví dụ khi mọi thứ healthy:

{
  "status": "healthy",
  "timestamp": "2026-03-21T10:00:00.000Z",
  "uptime": 3600,
  "version": "1.0.0",
  "checks": {
    "database": { "status": "up", "latency": 2 },
    "redis": { "status": "up", "latency": 1 }
  }
}

Response ví dụ khi Redis bị down:

{
  "status": "degraded",
  "timestamp": "2026-03-21T10:00:00.000Z",
  "uptime": 3600,
  "version": "1.0.0",
  "checks": {
    "database": { "status": "up", "latency": 2 },
    "redis": { "status": "down", "latency": 5000, "error": "Connection refused" }
  }
}

Đăng ký health routes trong app:

// src/app.ts (thêm vào app setup hiện tại)
 
import healthRoutes from './routes/healthRoutes';
 
// Health checks — không cần auth, không rate limiting
app.use(healthRoutes);

Structured Logging với Pino

console.log ổn cho development. Trong production, bạn cần structured JSON logs có thể được parse bởi log aggregation tools như ELK Stack, Datadog, hay CloudWatch.

Install Pino

npm install pino pino-http
npm install -D pino-pretty  # Pretty printing cho development

Logger Setup

// src/config/logger.ts
 
import pino from 'pino';
import { env } from './env';
 
export const logger = pino({
  level: env.NODE_ENV === 'production' ? 'info' : 'debug',
  transport: env.NODE_ENV === 'development'
    ? { target: 'pino-pretty', options: { colorize: true } }
    : undefined,
  base: {
    service: 'url-shortener',
    environment: env.NODE_ENV,
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Redact sensitive fields
  redact: ['req.headers.authorization', 'req.headers.cookie'],
});

HTTP Request Logging

// src/middleware/requestLogger.ts
 
import pinoHttp from 'pino-http';
import { logger } from '../config/logger';
 
export const requestLogger = pinoHttp({
  logger,
  // Không log health check requests (quá ồn)
  autoLogging: {
    ignore: (req) => req.url === '/health/live' || req.url === '/health/ready',
  },
  customSuccessMessage: (req, res) => {
    return `${req.method} ${req.url} ${res.statusCode}`;
  },
  customErrorMessage: (req, res) => {
    return `${req.method} ${req.url} ${res.statusCode} ERROR`;
  },
  // Thêm custom properties vào mỗi log line
  customProps: (req) => ({
    requestId: req.headers['x-request-id'] || crypto.randomUUID(),
  }),
});

Sử Dụng Logger

// Thay thế console.log ở mọi nơi
import { logger } from '../config/logger';
 
// Structured log với context
logger.info({ shortCode: 'abc123', userId: 'user-1' }, 'URL shortened successfully');
 
// Error với stack trace
logger.error({ err: error, shortCode: 'abc123' }, 'Failed to resolve short code');
 
// Performance timing
const start = Date.now();
await someOperation();
logger.info({ duration: Date.now() - start }, 'Database query completed');

Production log output (JSON — một dòng cho mỗi entry):

{"level":30,"time":1711018800000,"service":"url-shortener","environment":"production","requestId":"550e8400-e29b-41d4-a716-446655440000","shortCode":"abc123","msg":"URL shortened successfully"}

Development log output (pretty printed):

[10:00:00] INFO (url-shortener): URL shortened successfully
    shortCode: "abc123"
    userId: "user-1"

Thêm request logger vào app:

// src/app.ts
 
import { requestLogger } from './middleware/requestLogger';
 
app.use(requestLogger);

Prometheus Metrics

Prometheus scrape endpoint /metrics trên server và lưu time-series data. Sau đó bạn query bằng Grafana để có dashboards và alerts.

Install prom-client

npm install prom-client

Metrics Setup

// src/config/metrics.ts
 
import client from 'prom-client';
 
// Collect default Node.js metrics (memory, CPU, event loop)
client.collectDefaultMetrics({ prefix: 'urlshortener_' });
 
// ── Custom Metrics ────────────────────────────────
 
// HTTP request duration
export const httpRequestDuration = new client.Histogram({
  name: 'urlshortener_http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
});
 
// Tổng URLs đã shortened
export const urlsShortenedTotal = new client.Counter({
  name: 'urlshortener_urls_shortened_total',
  help: 'Total number of URLs shortened',
  labelNames: ['type'], // 'random' hoặc 'custom_alias'
});
 
// Tổng redirects đã serve
export const redirectsTotal = new client.Counter({
  name: 'urlshortener_redirects_total',
  help: 'Total number of redirects served',
  labelNames: ['status'], // 'success', 'not_found', 'expired'
});
 
// Cache hit/miss ratio
export const cacheOperations = new client.Counter({
  name: 'urlshortener_cache_operations_total',
  help: 'Redis cache operations',
  labelNames: ['operation'], // 'hit', 'miss', 'set', 'delete'
});
 
// Active database connections
export const dbConnections = new client.Gauge({
  name: 'urlshortener_db_connections_active',
  help: 'Number of active database connections',
});
 
// Request size
export const requestSize = new client.Histogram({
  name: 'urlshortener_request_size_bytes',
  help: 'Size of HTTP requests in bytes',
  buckets: [100, 500, 1000, 5000, 10000],
});
 
// Expose registry cho /metrics endpoint
export const register = client.register;

Metrics Middleware

// src/middleware/metricsMiddleware.ts
 
import { Request, Response, NextFunction } from 'express';
import { httpRequestDuration, requestSize } from '../config/metrics';
 
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
 
  // Track request size
  const contentLength = parseInt(req.headers['content-length'] || '0', 10);
  if (contentLength > 0) {
    requestSize.observe(contentLength);
  }
 
  // Track response time khi response hoàn thành
  res.on('finish', () => {
    const duration = (Date.now() - start) / 1000;
    const route = req.route?.path || req.path;
 
    httpRequestDuration
      .labels(req.method, route, res.statusCode.toString())
      .observe(duration);
  });
 
  next();
}

Metrics Endpoint

// src/routes/metricsRoutes.ts
 
import { Router, Request, Response } from 'express';
import { register } from '../config/metrics';
 
const router = Router();
 
router.get('/metrics', async (_req: Request, res: Response) => {
  try {
    res.set('Content-Type', register.contentType);
    res.end(await register.metrics());
  } catch (error) {
    res.status(500).end();
  }
});
 
export default router;

Instrument Services

Thêm metric tracking vào URL service:

// src/services/urlService.ts (thêm vào methods hiện tại)
 
import { urlsShortenedTotal, redirectsTotal, cacheOperations } from '../config/metrics';
 
async shortenUrl(url: string, customAlias?: string) {
  // ... logic hiện tại ...
 
  urlsShortenedTotal.labels(customAlias ? 'custom_alias' : 'random').inc();
 
  return result;
}
 
async resolveAndTrack(shortCode: string) {
  // Kiểm tra cache trước
  const cached = await redisClient.get(`url:${shortCode}`);
 
  if (cached) {
    cacheOperations.labels('hit').inc();
    redirectsTotal.labels('success').inc();
    return cached;
  }
 
  cacheOperations.labels('miss').inc();
 
  // ... database lookup ...
 
  if (!url) {
    redirectsTotal.labels('not_found').inc();
    return null;
  }
 
  // Cache cho lần sau
  await redisClient.setex(`url:${shortCode}`, 3600, url.originalUrl);
  cacheOperations.labels('set').inc();
  redirectsTotal.labels('success').inc();
 
  return url.originalUrl;
}

Đăng ký metrics middleware và route:

// src/app.ts
 
import { metricsMiddleware } from './middleware/metricsMiddleware';
import metricsRoutes from './routes/metricsRoutes';
 
app.use(metricsMiddleware);
app.use(metricsRoutes);

Prometheus Configuration

# monitoring/prometheus.yml
 
global:
  scrape_interval: 15s
  evaluation_interval: 15s
 
scrape_configs:
  - job_name: 'url-shortener-api'
    metrics_path: '/metrics'
    static_configs:
      - targets: ['api:3000']
        labels:
          app: 'url-shortener'
          environment: 'production'
 
  - job_name: 'node-exporter'
    static_configs:
      - targets: ['node-exporter:9100']

Truy cập http://localhost:9090 để mở Prometheus UI và query metrics.


Grafana Dashboard Setup

1. Thêm Prometheus Làm Data Source

  1. Mở Grafana tại http://localhost:3001
  2. Login với admin / GRAFANA_PASSWORD của bạn
  3. Vào Configuration > Data Sources > Add data source
  4. Chọn Prometheus
  5. Set URL thành http://prometheus:9090
  6. Click Save & Test

2. Tạo Dashboard

Tạo JSON dashboard definition để import:

{
  "dashboard": {
    "title": "URL Shortener Dashboard",
    "panels": [
      {
        "title": "Request Rate (req/sec)",
        "type": "graph",
        "targets": [{
          "expr": "rate(urlshortener_http_request_duration_seconds_count[5m])",
          "legendFormat": "{{method}} {{route}}"
        }]
      },
      {
        "title": "Response Time (p95)",
        "type": "graph",
        "targets": [{
          "expr": "histogram_quantile(0.95, rate(urlshortener_http_request_duration_seconds_bucket[5m]))",
          "legendFormat": "p95 latency"
        }]
      },
      {
        "title": "URLs Shortened",
        "type": "stat",
        "targets": [{
          "expr": "urlshortener_urls_shortened_total"
        }]
      },
      {
        "title": "Cache Hit Rate",
        "type": "gauge",
        "targets": [{
          "expr": "rate(urlshortener_cache_operations_total{operation='hit'}[5m]) / (rate(urlshortener_cache_operations_total{operation='hit'}[5m]) + rate(urlshortener_cache_operations_total{operation='miss'}[5m])) * 100"
        }]
      },
      {
        "title": "Redirects per Second",
        "type": "graph",
        "targets": [{
          "expr": "rate(urlshortener_redirects_total[5m])",
          "legendFormat": "{{status}}"
        }]
      }
    ]
  }
}

Các Metrics Quan Trọng Cần Monitor

MetricNó cho bạn biết gìAlert threshold
Request rateLượng trafficSpike > 10x bình thường
p95 latencyUser experience> 500ms
Error rateApplication health> 1% requests
Cache hit rateHiệu quả của Redis< 80%
DB connection poolDatabase health> 80% pool đã dùng
Memory usageMức tiêu thụ resource> 80% limit
Redirect latencyPerformance core feature> 50ms

Production Hardening

Security Middleware

npm install helmet cors compression
npm install -D @types/cors @types/compression
// src/middleware/security.ts
 
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import { env } from '../config/env';
 
// Helmet set security headers
export const securityHeaders = helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
    },
  },
  // HSTS — bắt buộc HTTPS
  hsts: {
    maxAge: 31536000, // 1 năm
    includeSubDomains: true,
    preload: true,
  },
});
 
// CORS — giới hạn origins nào được gọi API
export const corsMiddleware = cors({
  origin: env.CORS_ORIGIN.split(','),
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400, // Cache preflight 24 giờ
});
 
// Compression — gzip responses để giảm bandwidth
export const compressionMiddleware = compression({
  // Không compress responses nhỏ hơn 1KB
  threshold: 1024,
  // Không compress nếu client không accept
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  },
});

Áp Dụng Security Middleware

// src/app.ts
 
import { securityHeaders, corsMiddleware, compressionMiddleware } from './middleware/security';
 
const app = express();
 
// Security middleware — áp dụng TRƯỚC TIÊN
app.use(securityHeaders);
app.use(corsMiddleware);
app.use(compressionMiddleware);
 
// Sau đó là middleware hiện tại
app.use(express.json({ limit: '10kb' }));
app.use(requestLogger);
app.use(metricsMiddleware);
 
// ... routes

Graceful Shutdown

Cập nhật server entry point để handle shutdown signals đúng cách:

// src/index.ts
 
import app from './app';
import { env } from './config/env';
import { logger } from './config/logger';
import { PrismaClient } from '@prisma/client';
import { redisClient } from './config/redis';
 
const prisma = new PrismaClient();
 
const server = app.listen(env.PORT, () => {
  logger.info({ port: env.PORT, env: env.NODE_ENV }, 'Server started');
});
 
// Graceful shutdown
async function shutdown(signal: string) {
  logger.info({ signal }, 'Shutdown signal received');
 
  // Ngừng nhận connections mới
  server.close(async () => {
    logger.info('HTTP server closed');
 
    // Đóng database connection
    await prisma.$disconnect();
    logger.info('Database connection closed');
 
    // Đóng Redis connection
    await redisClient.quit();
    logger.info('Redis connection closed');
 
    process.exit(0);
  });
 
  // Force exit sau 30 giây
  setTimeout(() => {
    logger.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30000);
}
 
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
 
// Handle uncaught errors
process.on('uncaughtException', (error) => {
  logger.fatal({ err: error }, 'Uncaught exception');
  process.exit(1);
});
 
process.on('unhandledRejection', (reason) => {
  logger.fatal({ err: reason }, 'Unhandled rejection');
  process.exit(1);
});

Tại sao graceful shutdown quan trọng: Không có nó, mỗi lần deploy sẽ kill in-flight requests. Users thấy 502 errors. Database connections bị leak. Với graceful shutdown, server hoàn thành xử lý requests hiện tại, đóng connections sạch sẽ, rồi mới exit.


Nginx Reverse Proxy

Nginx đứng trước mọi thứ, handle SSL termination, serve static files và route requests.

# nginx/nginx.conf
 
events {
    worker_connections 1024;
}
 
http {
    # Rate limiting zone
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
 
    # Logging format
    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    '$request_time';
 
    upstream api_backend {
        server api:3000;
    }
 
    upstream frontend_backend {
        server frontend:80;
    }
 
    server {
        listen 80;
        server_name yourdomain.com;
 
        # Redirect HTTP sang HTTPS
        return 301 https://$server_name$request_uri;
    }
 
    server {
        listen 443 ssl http2;
        server_name yourdomain.com;
 
        # SSL certificates (dùng Let's Encrypt / certbot)
        ssl_certificate /etc/nginx/ssl/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
 
        # Security headers
        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode=block";
 
        # Gzip compression
        gzip on;
        gzip_types text/plain text/css application/json application/javascript;
        gzip_min_length 1000;
 
        # API routes
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://api_backend;
            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_set_header X-Forwarded-Proto $scheme;
        }
 
        # Short code redirects (single path segment, không bắt đầu bằng api/health/metrics)
        location ~ ^/[a-zA-Z0-9]{5,10}$ {
            proxy_pass http://api_backend;
            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_set_header X-Forwarded-Proto $scheme;
        }
 
        # Health checks (không rate limiting)
        location /health/ {
            proxy_pass http://api_backend;
        }
 
        # Metrics (giới hạn internal network)
        location /metrics {
            allow 10.0.0.0/8;
            allow 172.16.0.0/12;
            deny all;
            proxy_pass http://api_backend;
        }
 
        # Frontend (tất cả còn lại)
        location / {
            proxy_pass http://frontend_backend;
            proxy_set_header Host $host;
        }
    }
}

Các quyết định quan trọng:

  • SSL termination tại Nginx — API và frontend không cần handle HTTPS
  • Rate limiting tại Nginx — tuyến phòng thủ đầu tiên trước khi requests đến app
  • Metrics giới hạn cho internal network — không expose Prometheus data ra public
  • Short code regex — routes như /aBc123 đi đến API, mọi thứ khác đến frontend

GitHub Actions CI/CD Pipeline

Tự động hóa mọi thứ: lint, test, build và deploy mỗi khi push.

# .github/workflows/ci-cd.yml
 
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ── Lint & Type Check ──────────────────────────
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
 
  # ── Unit & Integration Tests ───────────────────
  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
 
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: url_shortener_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: test-password
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - run: npm ci
      - run: npx prisma generate
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://postgres:test-password@localhost:5432/url_shortener_test
 
      - run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://postgres:test-password@localhost:5432/url_shortener_test
          REDIS_URL: redis://localhost:6379
          JWT_SECRET: test-secret-at-least-32-characters-long
          NODE_ENV: test
 
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
 
  # ── Build Docker Image ─────────────────────────
  build:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
 
    permissions:
      contents: read
      packages: write
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build and push API image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-api:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-api:${{ github.sha }}
 
      - name: Build and push Frontend image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:${{ github.sha }}
 
  # ── Deploy ─────────────────────────────────────
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    environment: production
 
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/url-shortener
 
            # Pull latest images
            docker compose pull
 
            # Chạy database migrations
            docker compose run --rm api npx prisma migrate deploy
 
            # Restart với zero downtime
            docker compose up -d --remove-orphans
 
            # Verify deployment
            sleep 5
            curl -f http://localhost:3000/health/ready || exit 1
 
            echo "✅ Deployment successful"

CI/CD Flow

GitHub Secrets Cần Thiết

SecretMô tả
SERVER_HOSTIP hoặc hostname server production
SERVER_USERSSH username
SSH_PRIVATE_KEYSSH private key cho deployment

GITHUB_TOKEN tự động có sẵn — không cần setup cho GitHub Container Registry.


Production Checklist

Trước khi go live, verify mọi thứ trong danh sách này:

Security

  • Tất cả secrets trong environment variables (không trong code)
  • Helmet security headers enabled
  • CORS giới hạn cho domain của bạn
  • Rate limiting trên API endpoints
  • Non-root user trong Docker containers
  • SSL/TLS configured (HTTPS only)
  • /metrics endpoint giới hạn cho internal network

Reliability

  • Health check endpoints responding
  • Graceful shutdown handle SIGTERM
  • Database connection pooling configured
  • Redis connection với retry logic
  • Uncaught exception handlers
  • Docker healthchecks cho tất cả services

Observability

  • Structured JSON logging (không console.log)
  • Request/response logging với correlation IDs
  • Prometheus metrics exposed
  • Grafana dashboard configured
  • Key metrics: latency, error rate, cache hit rate

Performance

  • Gzip compression enabled
  • Multi-stage Docker builds (images nhỏ)
  • Static assets cached với long expiry
  • Database queries indexed
  • Redis caching trên hot paths

CI/CD

  • Automated linting và type checking
  • Test suite chạy trên mỗi PR
  • Docker images built và pushed tự động
  • Zero-downtime deployment
  • Rollback strategy đã document

Deployment Commands Cheat Sheet

# ── Local Development ────────────────────────────
docker compose up -d postgres redis     # Start dependencies only
npm run dev                              # Chạy API với hot reload
 
# ── Full Stack (Local) ───────────────────────────
docker compose up -d --build             # Build và start mọi thứ
docker compose logs -f api               # Follow API logs
docker compose exec api npx prisma studio  # Mở Prisma Studio
 
# ── Production ───────────────────────────────────
docker compose -f docker-compose.yml up -d --build
docker compose exec api npx prisma migrate deploy
docker compose ps                        # Kiểm tra service status
 
# ── Monitoring ───────────────────────────────────
curl http://localhost:3000/health/ready  # Kiểm tra health
curl http://localhost:3000/metrics       # Xem Prometheus metrics
# Grafana: http://localhost:3001
# Prometheus: http://localhost:9090
 
# ── Troubleshooting ──────────────────────────────
docker compose logs api --tail 100       # 100 dòng log API cuối
docker compose exec postgres psql -U postgres -d url_shortener  # DB shell
docker compose exec redis redis-cli      # Redis shell
docker compose restart api               # Restart API only

Tổng Kết Series: Chúng Ta Đã Build Gì

Qua 10 bài viết, chúng ta đã build một production-ready URL shortener từ thư mục trống. Hãy nhìn lại mỗi phase đã dạy chúng ta gì:

BàiPhaseCác Khái Niệm Chính
#1Series OverviewArchitecture, tech stack decisions, system design
#2Project Setup & APIExpress + TypeScript, REST API design, validation
#3Database DesignPostgreSQL, Prisma ORM, migrations, indexing
#4Short Code GenerationBase62 encoding, collision handling, custom aliases
#5Redirect Engine & Analytics301/302 redirects, click tracking, aggregation
#6Caching with RedisCache-aside pattern, TTL, rate limiting
#7AuthenticationJWT, bcrypt, middleware auth, API keys
#8Frontend with ReactReact, Vite, charts, QR codes, dashboard
#9Testing StrategyUnit, integration, load testing với Vitest + k6
#10Deployment (bài này)Docker, CI/CD, monitoring, production hardening

Full Tech Stack


Tiếp Theo Là Gì

Chúc mừng — bạn đã build xong một application hoàn chỉnh, deployed lên production. Đây là một số hướng để explore tiếp:

Feature Extensions

  • Link expiration scheduler — background job để deactivate expired URLs
  • Custom domains — cho users mang domain riêng của họ
  • Link previews — hiện preview page trước khi redirect
  • Bulk URL import — CSV upload cho batch shortening
  • Webhooks — notify users khi links đạt click milestones

Infrastructure Improvements

  • Kubernetes deployment — orchestrate bằng Helm charts
  • CDN integration — CloudFlare hoặc AWS CloudFront cho global edge caching
  • Database replicas — read replicas cho analytics queries
  • Message queue — RabbitMQ hoặc Kafka cho async click processing
  • Blue-green deployments — zero-downtime releases với traffic switching

Learning Paths

  • System design interviews — giờ bạn có thể discuss URL shorteners với real implementation experience
  • Microservices — tách monolith thành separate services (redirect service, analytics service, auth service)
  • Serverless — viết lại redirect engine dưới dạng Cloudflare Worker cho edge performance
  • Open source — publish shortener của bạn và nhận contributions

Các skills bạn đã học — API design, database modeling, caching, auth, testing, containerization, CI/CD, monitoring — là những skills tương tự được dùng để build systems ở mọi scale. Nhưng chúng ta chưa xong — trong phase tiếp theo, chúng ta sẽ thêm admin panel với role-based access control và user management.


Series: Build a URL Shortener
Previous: Phase 8: Testing Strategy
Next: Phase 10: Admin Panel — RBAC & User Management

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.