Back to blog

Xây Dựng Video Platform: Deployment & Production (Docker + VPS)

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Deployment & Production (Docker + VPS)

Chúng ta đã build 14 phase của một video streaming platform — authentication, streaming, transcoding, payments, analytics, security, và testing. Mọi thứ chạy ngon lành trên localhost. Nhưng một platform chỉ chạy trên laptop của bạn thì không phải platform — nó chỉ là demo thôi.

Bài viết cuối cùng này sẽ đưa project từ development lên production. Chúng ta sẽ containerize mọi thứ với Docker, configure Nginx với SSL, chạy database migrations an toàn, automate backups, viết deployment script, và thêm monitoring với Spring Boot Actuator. Khi xong, bạn sẽ có một video platform production-ready chạy trên VPS.

Thời gian: 4–5 giờ
Yêu cầu: Phase 13: Testing Strategy

Những gì chúng ta sẽ build:
✅ Multi-stage Docker builds cho Spring Boot và Next.js
✅ Docker Compose production configuration với tất cả services
✅ Nginx reverse proxy với SSL termination và HLS serving
✅ Flyway production migrations với safety checks
✅ Automated backup strategy (database + video files)
✅ One-command deploy script với health checks
✅ Spring Boot Actuator monitoring và alerting


Production Architecture

Đây là full production deployment architecture:

Mỗi component chạy trong Docker container riêng, orchestrate bởi Docker Compose. Bắt đầu build từng layer nhé.


Multi-Stage Docker Build: Spring Boot API

Multi-stage builds giữ production images nhỏ gọn. Chúng ta tách build environment (Maven + JDK) khỏi runtime environment (chỉ JRE).

API Dockerfile

# Dockerfile.api
 
# ── Stage 1: Build ──────────────────────────────
FROM eclipse-temurin:17-jdk-alpine AS builder
 
WORKDIR /app
 
# Copy Maven wrapper và POM trước (layer caching)
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B
 
# Copy source và build
COPY src ./src
RUN ./mvnw package -DskipTests -B
 
# ── Stage 2: Runtime ────────────────────────────
FROM eclipse-temurin:17-jre-alpine
 
# Cài FFmpeg cho video transcoding
RUN apk add --no-cache ffmpeg
 
WORKDIR /app
 
# Tạo non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
 
# Copy JAR đã build
COPY --from=builder /app/target/*.jar app.jar
 
# Tạo directories cho video storage
RUN mkdir -p /app/videos/uploads /app/videos/hls /app/videos/keys \
    && chown -R appuser:appgroup /app
 
USER appuser
 
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD wget -qO- http://localhost:8080/actuator/health || exit 1
 
EXPOSE 8080
 
ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", "app.jar"]

Những quyết định quan trọng:

  • eclipse-temurin — production-grade OpenJDK distribution
  • -XX:+UseContainerSupport — respect Docker memory limits
  • -XX:MaxRAMPercentage=75.0 — dùng 75% container memory cho JVM heap
  • Non-root user — security best practice
  • FFmpeg installed — video transcoding chạy trong cùng container
  • Health check — Docker monitor container qua Actuator

Multi-Stage Docker Build: Next.js Frontend

Frontend Dockerfile

# Dockerfile.web
 
# ── Stage 1: Dependencies ───────────────────────
FROM node:20-alpine AS deps
 
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
 
# ── Stage 2: Build ──────────────────────────────
FROM node:20-alpine AS builder
 
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
COPY . .
 
# Build-time environment variables
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
 
RUN npm run build
 
# ── Stage 3: Runtime ────────────────────────────
FROM node:20-alpine
 
WORKDIR /app
 
# Tạo non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
 
# Copy chỉ những gì cần cho production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
COPY --from=builder /app/next.config.mjs ./
 
USER appuser
 
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD wget -qO- http://localhost:3000/api/health || exit 1
 
EXPOSE 3000
 
CMD ["npm", "start"]

Three-stage build giữ final image tối thiểu:

  1. deps — install production dependencies only
  2. builder — compile Next.js với full dev dependencies
  3. runtime — copy chỉ built output và production node_modules

Docker Compose: Production Configuration

Đây là trái tim của deployment — một file duy nhất define mọi service, volume, và network.

docker-compose.prod.yml

version: "3.8"
 
services:
  # ── PostgreSQL ────────────────────────────────
  db:
    image: postgres:16-alpine
    container_name: vidplatform-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  # ── Redis ─────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: vidplatform-redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  # ── Spring Boot API ───────────────────────────
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile.api
    container_name: vidplatform-api
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: production
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME}
      SPRING_DATASOURCE_USERNAME: ${DB_USER}
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
      VIDEO_STORAGE_PATH: /app/videos
      HLS_BASE_URL: ${HLS_BASE_URL}
    volumes:
      - video_data:/app/videos
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: "2.0"
 
  # ── Next.js Frontend ──────────────────────────
  web:
    build:
      context: ./frontend
      dockerfile: Dockerfile.web
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
    container_name: vidplatform-web
    restart: unless-stopped
    environment:
      NODE_ENV: production
    depends_on:
      - api
    networks:
      - backend
 
  # ── Nginx Reverse Proxy ───────────────────────
  nginx:
    image: nginx:alpine
    container_name: vidplatform-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
      - video_data:/var/www/videos:ro
    depends_on:
      - api
      - web
    networks:
      - backend
 
  # ── Certbot (SSL) ────────────────────────────
  certbot:
    image: certbot/certbot
    container_name: vidplatform-certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h; done'"
 
volumes:
  postgres_data:
  redis_data:
  video_data:
 
networks:
  backend:
    driver: bridge

Service Communication Flow


Environment Variables

Tạo file .env trên production server — đừng bao giờ commit file này vào git.

# .env (production)
 
# ── Database ─────────────────────────────────────
DB_NAME=vidplatform
DB_USER=vidplatform
DB_PASSWORD=generate-a-strong-password-here
 
# ── Redis ────────────────────────────────────────
REDIS_PASSWORD=generate-another-strong-password
 
# ── JWT ──────────────────────────────────────────
JWT_SECRET=at-least-64-chars-random-string-here
 
# ── Stripe ───────────────────────────────────────
STRIPE_SECRET_KEY=sk_live_your_key
STRIPE_WEBHOOK_SECRET=whsec_your_secret
 
# ── URLs ─────────────────────────────────────────
DOMAIN=yourdomain.com
NEXT_PUBLIC_API_URL=https://yourdomain.com/api
HLS_BASE_URL=https://yourdomain.com/videos/hls
 
# ── OAuth (optional) ────────────────────────────
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

Generate secure passwords:

# Generate random passwords
openssl rand -base64 32  # Cho DB_PASSWORD
openssl rand -base64 32  # Cho REDIS_PASSWORD
openssl rand -base64 48  # Cho JWT_SECRET

Nginx Configuration

Nginx xử lý SSL termination, reverse proxying, HLS video serving, và security headers — tất cả trong một chỗ.

Main Configuration

# nginx/nginx.conf
 
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
 
events {
    worker_connections 1024;
    use epoll;
}
 
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
 
    # Logging format
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for" '
                    '$request_time';
 
    access_log /var/log/nginx/access.log main;
 
    # Performance
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
 
    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml text/javascript image/svg+xml;
 
    # Rate limiting zones
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
 
    # File upload size (cho video uploads)
    client_max_body_size 2G;
 
    include /etc/nginx/conf.d/*.conf;
}

Site Configuration với SSL

# nginx/conf.d/vidplatform.conf
 
# Redirect HTTP sang HTTPS
server {
    listen 80;
    server_name yourdomain.com;
 
    # Certbot challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
 
    location / {
        return 301 https://$host$request_uri;
    }
}
 
# Main HTTPS server
server {
    listen 443 ssl http2;
    server_name yourdomain.com;
 
    # ── SSL Configuration ─────────────────────────
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
 
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
 
    # ── Security Headers ──────────────────────────
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header Content-Security-Policy
        "default-src 'self'; "
        "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com; "
        "style-src 'self' 'unsafe-inline'; "
        "img-src 'self' data: https:; "
        "connect-src 'self' https://api.stripe.com; "
        "frame-src https://js.stripe.com; "
        always;
 
    # ── API Proxy ─────────────────────────────────
    location /api/ {
        limit_req zone=api burst=50 nodelay;
 
        proxy_pass http://api:8080;
        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;
 
        # Timeout cho long-running requests (video upload)
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
 
    # ── Auth endpoints (rate limit chặt hơn) ──────
    location /api/auth/ {
        limit_req zone=auth burst=3 nodelay;
 
        proxy_pass http://api:8080;
        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;
    }
 
    # ── Stripe Webhook (không rate limit) ─────────
    location /api/webhooks/stripe {
        proxy_pass http://api:8080;
        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;
 
        # Stripe cần raw body để verify signature
        proxy_set_header Content-Type $content_type;
        proxy_pass_request_body on;
    }
 
    # ── HLS Video Streaming (secure_link) ─────────
    location /videos/hls/ {
        alias /var/www/videos/hls/;
 
        # Secure link validation
        secure_link $arg_token,$arg_expires;
        secure_link_md5 "$secure_link_expires$uri$remote_addr your-secure-link-secret";
 
        # Deny nếu token invalid hoặc expired
        if ($secure_link = "") { return 403; }
        if ($secure_link = "0") { return 410; }
 
        # HLS-specific headers
        add_header Cache-Control "private, max-age=3600";
        add_header Access-Control-Allow-Origin "https://yourdomain.com";
 
        # MIME types cho HLS
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
            application/octet-stream key;
        }
    }
 
    # ── Actuator (chỉ internal) ──────────────────
    location /actuator/ {
        # Chỉ cho phép từ internal networks
        allow 172.16.0.0/12;  # Docker network
        allow 10.0.0.0/8;
        deny all;
 
        proxy_pass http://api:8080;
        proxy_set_header Host $host;
    }
 
    # ── Next.js Frontend ──────────────────────────
    location / {
        proxy_pass http://web:3000;
        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;
 
        # WebSocket support (cho HMR, Next.js)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

SSL Certificate Setup

# Lần đầu cài SSL certificate với Let's Encrypt
# Bước 1: Start Nginx với HTTP only (comment SSL block trước)
docker compose -f docker-compose.prod.yml up -d nginx
 
# Bước 2: Lấy certificate
docker compose -f docker-compose.prod.yml run --rm certbot \
    certonly --webroot --webroot-path=/var/www/certbot \
    -d yourdomain.com --email [email protected] --agree-tos
 
# Bước 3: Uncomment SSL block trong nginx.conf, restart
docker compose -f docker-compose.prod.yml restart nginx
 
# Verify SSL
curl -I https://yourdomain.com

Certbot tự động renew certificates mỗi 12 giờ (qua entrypoint của container). Nginx reload được handle bởi renewal hooks.


Spring Boot Production Profile

application-production.yml

# src/main/resources/application-production.yml
 
spring:
  # ── Database ──────────────────────────────────
  datasource:
    url: ${SPRING_DATASOURCE_URL}
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000
 
  # ── JPA ───────────────────────────────────────
  jpa:
    hibernate:
      ddl-auto: validate  # KHÔNG BAO GIỜ dùng update/create trên production
    show-sql: false
    properties:
      hibernate:
        format_sql: false
        generate_statistics: false
 
  # ── Flyway ────────────────────────────────────
  flyway:
    enabled: true
    baseline-on-migrate: false
    validate-on-migrate: true
    locations: classpath:db/migration
 
  # ── Redis ─────────────────────────────────────
  data:
    redis:
      host: ${SPRING_DATA_REDIS_HOST}
      port: 6379
      password: ${SPRING_DATA_REDIS_PASSWORD}
      timeout: 5000
 
  # ── Cache ─────────────────────────────────────
  cache:
    type: redis
    redis:
      time-to-live: 600000  # 10 phút default
 
# ── Server ────────────────────────────────────────
server:
  port: 8080
  shutdown: graceful
  tomcat:
    max-threads: 200
    accept-count: 100
    connection-timeout: 10000
 
# ── Actuator ──────────────────────────────────────
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  health:
    db:
      enabled: true
    redis:
      enabled: true
    diskspace:
      enabled: true
 
# ── Logging ───────────────────────────────────────
logging:
  level:
    root: WARN
    com.vidplatform: INFO
    org.springframework.web: WARN
    org.hibernate: WARN
  pattern:
    console: '{"time":"%d","level":"%p","logger":"%logger","msg":"%m"}%n'
 
# ── Video Storage ─────────────────────────────────
video:
  storage:
    path: ${VIDEO_STORAGE_PATH:/app/videos}
    max-upload-size: 2GB
  hls:
    base-url: ${HLS_BASE_URL}

Những production settings quan trọng:

  • ddl-auto: validate — Hibernate chỉ validate schema, không bao giờ modify. Flyway lo migrations.
  • shutdown: graceful — Spring Boot đợi in-flight requests xong rồi mới shutdown
  • HikariCP tuning — connection pool sized cho production traffic
  • Structured JSON logging — dễ parse với log aggregation tools
  • Actuator — health checks, metrics, và Prometheus endpoint

Flyway Production Migrations

Flyway quản lý database schema changes bằng versioned migration files. Mỗi schema change là một SQL file mới — không ALTER TABLE thủ công trên production.

Migration File Structure

src/main/resources/db/migration/
├── V1__create_users_table.sql
├── V2__create_oauth_accounts_table.sql
├── V3__create_courses_tables.sql
├── V4__create_lessons_table.sql
├── V5__create_subscriptions_tables.sql
├── V6__create_lesson_progress_table.sql
├── V7__create_course_enrollments_table.sql
├── V8__add_payment_history_table.sql
└── V9__add_analytics_indexes.sql

Example Migration: Initial Schema

-- V1__create_users_table.sql
 
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255),
    name        VARCHAR(255) NOT NULL,
    avatar_url  VARCHAR(500),
    role        VARCHAR(20) NOT NULL DEFAULT 'USER',
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);

Example Migration: Thêm Indexes cho Analytics

-- V9__add_analytics_indexes.sql
 
-- Optimize analytics queries (thêm ở Phase 11)
CREATE INDEX idx_subscriptions_status_period
    ON subscriptions(status, current_period_end);
 
CREATE INDEX idx_lesson_progress_completed
    ON lesson_progress(completed) WHERE completed = TRUE;
 
CREATE INDEX idx_course_enrollments_progress
    ON course_enrollments(course_id, progress_pct);
 
-- Partial index: chỉ active subscriptions
CREATE INDEX idx_subscriptions_active
    ON subscriptions(user_id)
    WHERE status = 'ACTIVE';

Migration Safety Rules

Quy tắc vàng cho production migrations:

  1. Đừng bao giờ rename columns trực tiếp — add column mới, migrate data, rồi drop column cũ
  2. Đừng drop column cùng release — đánh dấu deprecated, remove ở release tiếp
  3. Luôn add indexes concurrently khi có thể (CREATE INDEX CONCURRENTLY)
  4. Test migrations trên bản copy của production data trước khi deploy
  5. Đừng bao giờ modify migration đã applied — tạo migration mới thay thế

Chạy Migrations

Flyway chạy tự động khi Spring Boot start (đã configure trong application-production.yml). Bạn cũng có thể chạy thủ công:

# Chạy migrations qua Spring Boot
docker compose -f docker-compose.prod.yml exec api \
    java -jar app.jar --spring.flyway.enabled=true
 
# Kiểm tra migration status
docker compose -f docker-compose.prod.yml exec db \
    psql -U vidplatform -d vidplatform \
    -c "SELECT version, description, installed_on, success FROM flyway_schema_history ORDER BY version;"

Backup Strategy

Mất data là thảm họa duy nhất mà bạn không recover được. Chúng ta cần backups cho hai thứ: PostgreSQL database và video files.

Database Backup Script

#!/bin/bash
# scripts/backup-db.sh
 
set -euo pipefail
 
# ── Configuration ─────────────────────────────────
BACKUP_DIR="/opt/backups/db"
RETENTION_DAYS=30
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="vidplatform_${TIMESTAMP}.sql.gz"
 
# Load environment
source /opt/vidplatform/.env
 
# ── Tạo backup ───────────────────────────────────
echo "[$(date)] Starting database backup..."
 
mkdir -p "$BACKUP_DIR"
 
docker compose -f /opt/vidplatform/docker-compose.prod.yml exec -T db \
    pg_dump -U "$DB_USER" -d "$DB_NAME" \
    --no-owner --no-privileges --format=plain \
    | gzip > "$BACKUP_DIR/$BACKUP_FILE"
 
BACKUP_SIZE=$(du -sh "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
echo "[$(date)] Backup hoàn tất: $BACKUP_FILE ($BACKUP_SIZE)"
 
# ── Xóa backups cũ ──────────────────────────────
echo "[$(date)] Dọn backups cũ hơn $RETENTION_DAYS ngày..."
find "$BACKUP_DIR" -name "vidplatform_*.sql.gz" -mtime +$RETENTION_DAYS -delete
 
# ── Verify backup ────────────────────────────────
if gzip -t "$BACKUP_DIR/$BACKUP_FILE" 2>/dev/null; then
    echo "[$(date)] Backup verified thành công"
else
    echo "[$(date)] LỖI: File backup bị corrupted!"
    exit 1
fi
 
echo "[$(date)] Database backup hoàn tất"

Video Files Backup Script

#!/bin/bash
# scripts/backup-videos.sh
 
set -euo pipefail
 
# ── Configuration ─────────────────────────────────
VIDEO_SOURCE="/opt/vidplatform/videos"
BACKUP_DEST="/opt/backups/videos"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="/var/log/vidplatform/video-backup.log"
 
# ── Incremental sync với rsync ───────────────────
echo "[$(date)] Starting video backup..." | tee -a "$LOG_FILE"
 
mkdir -p "$BACKUP_DEST"
 
rsync -avz --progress \
    --exclude="*.tmp" \
    --exclude="*.processing" \
    "$VIDEO_SOURCE/" "$BACKUP_DEST/" \
    2>&1 | tee -a "$LOG_FILE"
 
TOTAL_SIZE=$(du -sh "$BACKUP_DEST" | cut -f1)
echo "[$(date)] Video backup hoàn tất. Tổng size: $TOTAL_SIZE" | tee -a "$LOG_FILE"

Cron Schedule

# /etc/cron.d/vidplatform-backups
 
# Database backup: hàng ngày lúc 2 AM
0 2 * * * root /opt/vidplatform/scripts/backup-db.sh >> /var/log/vidplatform/db-backup.log 2>&1
 
# Video sync: hàng ngày lúc 3 AM
0 3 * * * root /opt/vidplatform/scripts/backup-videos.sh >> /var/log/vidplatform/video-backup.log 2>&1
 
# Cleanup logs cũ: hàng tuần vào Chủ nhật
0 4 * * 0 root find /var/log/vidplatform -name "*.log" -mtime +90 -delete

Restore Procedure

# ── Restore database ─────────────────────────────
# Bước 1: Stop API (ngăn writes)
docker compose -f docker-compose.prod.yml stop api
 
# Bước 2: Restore từ backup
gunzip -c /opt/backups/db/vidplatform_20260323_020000.sql.gz | \
    docker compose -f docker-compose.prod.yml exec -T db \
    psql -U vidplatform -d vidplatform
 
# Bước 3: Restart API
docker compose -f docker-compose.prod.yml start api
 
# ── Restore videos ───────────────────────────────
rsync -avz /opt/backups/videos/ /opt/vidplatform/videos/

Deploy Script

Một script duy nhất xử lý toàn bộ deployment: pull code, build images, chạy migrations, và restart services với health checks.

deploy.sh

#!/bin/bash
# scripts/deploy.sh
 
set -euo pipefail
 
# ── Configuration ─────────────────────────────────
APP_DIR="/opt/vidplatform"
COMPOSE_FILE="$APP_DIR/docker-compose.prod.yml"
LOG_FILE="/var/log/vidplatform/deploy.log"
MAX_WAIT=120  # giây chờ health checks
 
# Colors cho output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
 
log() { echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }
 
# ── Pre-flight checks ────────────────────────────
log "${YELLOW}Bắt đầu deployment...${NC}"
 
if [ ! -f "$APP_DIR/.env" ]; then
    log "${RED}LỖI: Không tìm thấy file .env${NC}"
    exit 1
fi
 
cd "$APP_DIR"
 
# ── Pull latest code ─────────────────────────────
log "Pulling code mới nhất..."
git fetch origin main
git reset --hard origin/main
 
# ── Backup database trước khi deploy ─────────────
log "Tạo pre-deploy backup..."
bash scripts/backup-db.sh
 
# ── Build new images ─────────────────────────────
log "Building Docker images..."
docker compose -f "$COMPOSE_FILE" build --no-cache api web
 
# ── Rolling restart ──────────────────────────────
log "Restarting services..."
 
# Restart API trước (chạy Flyway migrations khi startup)
docker compose -f "$COMPOSE_FILE" up -d --no-deps api
log "Đợi API healthy..."
 
WAIT=0
until docker compose -f "$COMPOSE_FILE" exec api wget -qO- http://localhost:8080/actuator/health 2>/dev/null | grep -q '"status":"UP"'; do
    WAIT=$((WAIT + 5))
    if [ $WAIT -ge $MAX_WAIT ]; then
        log "${RED}LỖI: API health check thất bại sau ${MAX_WAIT}s${NC}"
        log "Rolling back..."
        docker compose -f "$COMPOSE_FILE" down api
        docker compose -f "$COMPOSE_FILE" up -d api  # Image trước đó
        exit 1
    fi
    sleep 5
    log "  Đang chờ... (${WAIT}s)"
done
log "${GREEN}API đã healthy${NC}"
 
# Restart frontend
docker compose -f "$COMPOSE_FILE" up -d --no-deps web
sleep 5
 
# Reload Nginx để pick up config changes
docker compose -f "$COMPOSE_FILE" exec nginx nginx -s reload
log "${GREEN}Nginx đã reload${NC}"
 
# ── Final health check ───────────────────────────
log "Chạy health checks cuối cùng..."
 
check_service() {
    local name=$1
    local url=$2
    if curl -sf "$url" > /dev/null 2>&1; then
        log "${GREEN}  ✓ $name${NC}"
        return 0
    else
        log "${RED}  ✗ $name${NC}"
        return 1
    fi
}
 
FAILED=0
check_service "API Health" "http://localhost:8080/actuator/health" || FAILED=1
check_service "Frontend" "http://localhost:3000" || FAILED=1
check_service "Nginx HTTPS" "https://localhost" || FAILED=1
 
if [ $FAILED -eq 1 ]; then
    log "${RED}Một số health checks thất bại! Kiểm tra logs.${NC}"
    docker compose -f "$COMPOSE_FILE" ps
    exit 1
fi
 
# ── Cleanup ──────────────────────────────────────
log "Dọn Docker images không dùng..."
docker image prune -f
 
log "${GREEN}Deployment hoàn tất!${NC}"
docker compose -f "$COMPOSE_FILE" ps

Deployment Flow


Spring Boot Actuator Monitoring

Actuator cung cấp production-ready monitoring endpoints out of the box. Chúng ta đã configure nó trong application-production.yml — đây là cách sử dụng.

Health Check Endpoint

// src/main/java/com/vidplatform/monitoring/CustomHealthIndicator.java
 
@Component
public class VideoStorageHealthIndicator implements HealthIndicator {
 
    @Value("${video.storage.path}")
    private String videoStoragePath;
 
    @Override
    public Health health() {
        File storageDir = new File(videoStoragePath);
 
        if (!storageDir.exists() || !storageDir.canWrite()) {
            return Health.down()
                    .withDetail("path", videoStoragePath)
                    .withDetail("error", "Storage directory not writable")
                    .build();
        }
 
        long usableSpace = storageDir.getUsableSpace();
        long totalSpace = storageDir.getTotalSpace();
        double usedPercentage = ((double) (totalSpace - usableSpace) / totalSpace) * 100;
 
        Health.Builder builder = usedPercentage > 90
                ? Health.down().withDetail("warning", "Disk usage trên 90%")
                : Health.up();
 
        return builder
                .withDetail("path", videoStoragePath)
                .withDetail("usableSpaceGB", usableSpace / (1024 * 1024 * 1024))
                .withDetail("totalSpaceGB", totalSpace / (1024 * 1024 * 1024))
                .withDetail("usedPercentage", String.format("%.1f%%", usedPercentage))
                .build();
    }
}

Custom Metrics

// src/main/java/com/vidplatform/monitoring/VideoMetrics.java
 
@Component
public class VideoMetrics {
 
    private final Counter transcodingStarted;
    private final Counter transcodingCompleted;
    private final Counter transcodingFailed;
    private final Timer transcodingDuration;
    private final AtomicInteger activeTranscodings;
 
    public VideoMetrics(MeterRegistry registry) {
        this.transcodingStarted = Counter.builder("video.transcoding.started")
                .description("Số transcoding jobs đã bắt đầu")
                .register(registry);
 
        this.transcodingCompleted = Counter.builder("video.transcoding.completed")
                .description("Số transcoding jobs hoàn thành")
                .register(registry);
 
        this.transcodingFailed = Counter.builder("video.transcoding.failed")
                .description("Số transcoding jobs thất bại")
                .register(registry);
 
        this.transcodingDuration = Timer.builder("video.transcoding.duration")
                .description("Thời gian transcode videos")
                .register(registry);
 
        this.activeTranscodings = registry.gauge(
                "video.transcoding.active",
                new AtomicInteger(0)
        );
 
        // Gauge cho tổng số video
        Gauge.builder("video.storage.count", this, VideoMetrics::countVideoFiles)
                .description("Tổng số video files trong storage")
                .register(registry);
    }
 
    public void recordTranscodingStarted() {
        transcodingStarted.increment();
        activeTranscodings.incrementAndGet();
    }
 
    public void recordTranscodingCompleted(long durationMs) {
        transcodingCompleted.increment();
        activeTranscodings.decrementAndGet();
        transcodingDuration.record(durationMs, TimeUnit.MILLISECONDS);
    }
 
    public void recordTranscodingFailed() {
        transcodingFailed.increment();
        activeTranscodings.decrementAndGet();
    }
 
    private double countVideoFiles() {
        // Đếm file .m3u8 trong HLS directory
        try (Stream<Path> files = Files.walk(Path.of("/app/videos/hls"))) {
            return files.filter(p -> p.toString().endsWith(".m3u8")).count();
        } catch (IOException e) {
            return -1;
        }
    }
}

Prometheus Integration

Thêm Prometheus dependency để expose metrics ở format Prometheus:

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Metrics giờ available tại /actuator/prometheus:

# Sample output
curl http://localhost:8080/actuator/prometheus
 
# HELP video_transcoding_started_total Number of transcoding jobs started
# TYPE video_transcoding_started_total counter
video_transcoding_started_total 42.0
 
# HELP video_transcoding_duration_seconds Time taken to transcode videos
# TYPE video_transcoding_duration_seconds summary
video_transcoding_duration_seconds_count 38.0
video_transcoding_duration_seconds_sum 4567.8
 
# HELP video_transcoding_active Number of active transcoding jobs
# TYPE video_transcoding_active gauge
video_transcoding_active 2.0

Monitoring với Actuator Endpoints

# Health check (Docker và deploy script dùng cái này)
curl http://localhost:8080/actuator/health
# {
#   "status": "UP",
#   "components": {
#     "db": { "status": "UP" },
#     "redis": { "status": "UP" },
#     "videoStorage": { "status": "UP", "details": { "usedPercentage": "45.2%" } },
#     "diskSpace": { "status": "UP" }
#   }
# }
 
# Application info
curl http://localhost:8080/actuator/info
 
# JVM metrics
curl http://localhost:8080/actuator/metrics/jvm.memory.used
 
# Custom metrics
curl http://localhost:8080/actuator/metrics/video.transcoding.active

Server Setup Guide

Đây là hướng dẫn từng bước setup VPS mới cho video platform.

VPS Requirements

ResourceTối thiểuKhuyến nghị
CPU2 cores4 cores
RAM4 GB8 GB
Storage50 GB SSD200 GB+ SSD
OSUbuntu 22.04Ubuntu 22.04
Bandwidth1 TB/thángKhông giới hạn

FFmpeg transcoding rất ngốn CPU. Với platform có nhiều concurrent uploads, nên dùng 4+ cores.

Initial Server Setup

# ── 1. Update system ─────────────────────────────
sudo apt update && sudo apt upgrade -y
 
# ── 2. Tạo app user ─────────────────────────────
sudo adduser vidplatform
sudo usermod -aG sudo vidplatform
sudo usermod -aG docker vidplatform
 
# ── 3. Cài Docker ───────────────────────────────
curl -fsSL https://get.docker.com | sh
sudo systemctl enable docker
sudo systemctl start docker
 
# ── 4. Cài Docker Compose ───────────────────────
sudo apt install docker-compose-plugin -y
 
# ── 5. Configure firewall ───────────────────────
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
 
# ── 6. Setup project directory ──────────────────
sudo mkdir -p /opt/vidplatform
sudo chown vidplatform:vidplatform /opt/vidplatform
 
# ── 7. Clone repository ────────────────────────
su - vidplatform
cd /opt/vidplatform
git clone https://github.com/yourusername/vidplatform.git .
 
# ── 8. Tạo directories ─────────────────────────
mkdir -p nginx/conf.d certbot/conf certbot/www
mkdir -p /opt/backups/{db,videos}
mkdir -p /var/log/vidplatform
 
# ── 9. Configure environment ───────────────────
cp .env.example .env
nano .env  # Sửa với production values
 
# ── 10. Deploy! ─────────────────────────────────
bash scripts/deploy.sh

Swap Space (Quan trọng cho FFmpeg)

FFmpeg có thể ngốn rất nhiều memory khi transcoding. Thêm swap space:

# Tạo 4GB swap
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
 
# Thêm vĩnh viễn
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
 
# Verify
free -h

Production Checklist

Trước khi go live, verify mọi item trong checklist này:

Security

  • .env file không ở trong git (check .gitignore)
  • Database password ít nhất 32 ký tự
  • JWT secret ít nhất 64 ký tự
  • Stripe dùng live keys (không phải test keys)
  • SSL certificate valid và auto-renewing
  • Actuator endpoints restricted cho internal networks
  • ddl-auto set là validate (không phải update hay create)
  • Rate limiting đã configure cho auth và API endpoints

Reliability

  • Database backups chạy hàng ngày
  • Backup restore đã được test
  • Health checks configured cho tất cả containers
  • Deploy script có rollback khi failure
  • Graceful shutdown enabled (server.shutdown: graceful)
  • Container restart policy là unless-stopped

Performance

  • Gzip compression enabled trong Nginx
  • Redis caching configured cho hot data
  • HikariCP connection pool sized đúng
  • Next.js chạy production mode
  • Static assets có cache headers
  • JVM memory sized 75% container limit

Monitoring

  • Actuator health endpoint responds với tất cả components
  • Custom video storage health indicator active
  • Prometheus metrics đang được collect
  • Log files đang rotating (không đầy disk)
  • Deploy logs đang được capture

Common Production Issues

Issue: API Không Start Được

# Check logs
docker compose -f docker-compose.prod.yml logs api --tail=100
 
# Nguyên nhân phổ biến:
# 1. Database chưa ready → check depends_on health checks
# 2. Flyway migration failed → check migration SQL
# 3. Thiếu environment variable → check file .env
# 4. Port đã bị dùng → check: ss -tlnp | grep 8080

Issue: Video Playback Lỗi

# Check secure_link token
# Xem Nginx error logs
docker compose -f docker-compose.prod.yml logs nginx --tail=50
 
# Nguyên nhân phổ biến:
# 1. Token expired → check client clock sync
# 2. IP mismatch → IP user thay đổi (mạng di động)
# 3. HLS files chưa generate → check video_data volume mount
# 4. CORS error → check Access-Control-Allow-Origin header

Issue: Hết Disk Space

# Check disk usage
df -h
 
# Tìm files lớn
du -sh /opt/vidplatform/videos/*
du -sh /var/lib/docker/*
 
# Dọn Docker
docker system prune -a  # Xóa images/containers không dùng
 
# Dọn backups cũ
find /opt/backups -mtime +30 -delete

Issue: Memory Cao

# Check container resource usage
docker stats --no-stream
 
# Nếu API container dùng quá nhiều:
# 1. Giảm MaxRAMPercentage trong Dockerfile
# 2. Giảm HikariCP pool size
# 3. Check memory leaks trong transcoding

Complete Series Architecture

Chúc mừng — bạn đã build xong một video streaming platform hoàn chỉnh từ đầu! Đây là mọi thứ chúng ta đã build qua 15 bài viết:


Where to Go From Here

Bạn đã build xong một production-ready video streaming platform. Đây là những hướng để explore tiếp:

Feature Extensions

  • Multi-course bundles — gộp courses với discount pricing
  • Discussion forums — per-lesson comment threads cho student Q&A
  • Certificates — generate PDF completion certificates với unique verification codes
  • Live streaming — thêm RTMP ingest với FFmpeg cho live lessons
  • Mobile app — React Native app consume cùng API

Infrastructure Improvements

  • CDN integration — CloudFlare hoặc BunnyCDN cho global HLS delivery
  • Object storage — migrate video files từ disk sang S3/MinIO cho scalability
  • Kubernetes — orchestrate với Helm charts cho auto-scaling
  • Message queue — RabbitMQ cho async transcoding thay vì @Async
  • Database replicas — read replicas cho analytics queries

Monitoring & Observability

  • Grafana dashboards — visualize Prometheus metrics với custom panels
  • Distributed tracing — OpenTelemetry + Jaeger cho request tracing
  • Error tracking — Sentry cho real-time error monitoring
  • Uptime monitoring — external health checks với alerts
  • Log aggregation — ELK stack hoặc Loki cho centralized logging

Learning Paths

  • System design interviews — giờ bạn có thể discuss video streaming với real implementation experience
  • Microservices — tách thành separate services (auth, catalog, transcoding, billing)
  • Multi-tenancy — cho creators host course platforms riêng trên infrastructure của bạn
  • Open source — publish platform và build community

Những skills bạn đã học qua series này — API design, database modeling, video transcoding, caching, authentication, payment processing, testing, containerization, và deployment — là những skills dùng để build platforms ở mọi scale. Từ solo project đến production service phục vụ hàng nghìn students, fundamentals vẫn như nhau.


Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 13: Testing Strategy

📬 Đă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.