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:
- deps — install production dependencies only
- builder — compile Next.js với full dev dependencies
- 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: bridgeService 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-secretGenerate 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_SECRETNginx 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.comCertbot 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.sqlExample 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:
- Đừng bao giờ rename columns trực tiếp — add column mới, migrate data, rồi drop column cũ
- Đừng drop column cùng release — đánh dấu deprecated, remove ở release tiếp
- Luôn add indexes concurrently khi có thể (
CREATE INDEX CONCURRENTLY) - Test migrations trên bản copy của production data trước khi deploy
- Đừ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 -deleteRestore 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" psDeployment 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.0Monitoring 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.activeServer Setup Guide
Đây là hướng dẫn từng bước setup VPS mới cho video platform.
VPS Requirements
| Resource | Tối thiểu | Khuyến nghị |
|---|---|---|
| CPU | 2 cores | 4 cores |
| RAM | 4 GB | 8 GB |
| Storage | 50 GB SSD | 200 GB+ SSD |
| OS | Ubuntu 22.04 | Ubuntu 22.04 |
| Bandwidth | 1 TB/tháng | Khô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.shSwap 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 -hProduction Checklist
Trước khi go live, verify mọi item trong checklist này:
Security
-
.envfile 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-autoset làvalidate(không phảiupdatehaycreate) - 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 8080Issue: 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 headerIssue: 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 -deleteIssue: 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 transcodingComplete 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.