Docker Deploy
Đây là cách deploy production duy nhất cho
booking-api+booking-web. Single VPS (4–8 GB RAM), horizontal-scaling cho backend, zero-downtime rolling update. Postgres chạy native trên host; api / web / redis / caddy chạy trong Docker. Trang docs (documents.<domain>) deploy riêng — xemdeploy-docs-site.md.
1. Topology
┌─────────────────────────┐
INTERNET ──────► │ caddy (TLS edge) │ :80 / :443
│ Docker container │ auto-HTTPS + on-demand
└────────┬────────────────┘
│ (Docker bridge network)
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ api × N │ │ web × 1 │ │ redis │
│ :3010 │ │ :3000 │ │ :6379 │
└────┬─────┘ └──────────┘ └──────────┘
│
│ host.docker.internal:5432
▼
┌──────────────────┐
│ Postgres (NATIVE │
│ on the VPS host)│
└──────────────────┘
Tại sao thế:
- Postgres native — tránh tax disk I/O của Docker volume trên VPS RAM-tight.
- Redis SHARED — tất cả
apireplicas dùng chung 1 Redis → throttler/BullMQ counters đồng bộ. Không bao giờ chạy 1 Redis per replica (rate limit sẽ bị bypass). - Caddy trong Docker — TLS edge duy nhất (auto-HTTPS + on-demand TLS cho custom domain), reverse-proxy tới api/web theo service name. Thay nginx + certbot. Multi-replica round-robin: xem
custom-domain.md §12(dynamic upstreams / shared cert storage).
2. VPS bootstrap (1 lần đầu)
2.1 OS + swap (4GB VPS)
# Ubuntu 22.04+ / Debian 12+
sudo apt update && sudo apt upgrade -y
# Swap 4GB — Docker engine + image pulls có thể spike RAM, swap để buffer.
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
2.2 Docker + compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
sudo systemctl enable --now docker
# Apply group ngay không cần logout — newgrp spawn 1 sub-shell mới có
# group docker. Stay trong sub-shell này cho hết phiên SSH (exit khi đóng).
newgrp docker
docker --version # verify
Nếu sau này anh logout rồi login lại, KHÔNG cần
newgrp dockernữa — group đã permanent ở user (qua usermod -aG).
2.3 PostgreSQL native (TRÊN HOST, không trong Docker)
Postgres chạy native trên VPS để có direct disk I/O, tránh tax của Docker volume trên VPS RAM-tight.
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql
# Tạo user + database
sudo -u postgres psql <<SQL
CREATE USER glamvoo_user WITH PASSWORD 'STRONG_PASSWORD_HERE';
CREATE DATABASE glamvoo OWNER glamvoo_user;
SQL
Mở Postgres cho Docker bridge (172.16.0.0/12 cover toàn bộ Docker subnet ranges 172.16-172.31.x.x). Làm tuần tự từng bước, paste 1 lệnh → xem output → tiếp.
B1. Tìm path file config (đổi 16 thành version anh đang chạy nếu khác):
sudo -u postgres psql -c "SHOW config_file;"
sudo -u postgres psql -c "SHOW hba_file;"
Output ví dụ:
config_file = /etc/postgresql/16/main/postgresql.conf
hba_file = /etc/postgresql/16/main/pg_hba.conf
B2. Sửa listen_addresses = '*' (thay 16 nếu version khác):
sudo sed -i "s/^#*listen_addresses.*/listen_addresses = '*'/" /etc/postgresql/16/main/postgresql.conf
grep listen_addresses /etc/postgresql/16/main/postgresql.conf
Output phải là: listen_addresses = '*'
B3. Thêm rule pg_hba cho Docker bridge:
echo "host glamvoo glamvoo_user 172.16.0.0/12 scram-sha-256" \
| sudo tee -a /etc/postgresql/16/main/pg_hba.conf
B4. Restart Postgres (cần restart cho listen_addresses, reload không đủ):
sudo systemctl restart postgresql
B5. Verify:
sudo ss -tlnp | grep 5432
# Phải thấy: 0.0.0.0:5432 (KHÔNG phải 127.0.0.1:5432)
sudo -u postgres psql -c "SHOW listen_addresses;"
# Phải in: listen_addresses = *
Lý do dùng
'*'thay vì'localhost,172.17.0.1': docker-compose.prod.yml dùng custom bridge network (networks: booking), Docker assign subnet riêng (172.18.x, 172.19.x, ...) không cố định 172.17.x.pg_hba.confwhitelist phạm vi172.16.0.0/12rộng đủ cover mọi case. Public internet vẫn an toàn nhờ UFW (xem §11).
2.4 TLS edge — Caddy (cài đặt)
Caddy chạy TRONG Docker (image
caddy:2-alpine, servicecaddytrongdocker-compose.prod.yml). KHÔNG cài trên host (apt install caddy) —docker compose uplà kéo image + chạy. Caddy thay thế nginx + certbot, lo cả TLS:
- Platform domain (glamvoo.com, www, images, docs, app-dev): tự xin + gia hạn Let's Encrypt cert trong-process (không cron).
- Custom domain tenant (mysalon.com / book.mysalon.com):
on_demand_tlstự cấp cert ở lần HTTPS đầu, gated bởiask→api /internal/tls-allow(chỉ domain ACTIVE).
Prereq: Docker + docker compose đã có (xem §1); repo đã clone về /opt/booking-system (xem §2.5); DNS platform domain trỏ đúng IP VPS; port 80 + 443 mở (UFW §11) — port 80 bắt buộc cho ACME http-01.
Bước cài đặt (lần đầu):
cd /opt/booking-system
# 1. Tạo bcrypt hash cho basic-auth của docs.glamvoo.com (chạy 1 lần, không cần Caddy đang chạy)
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'mat-khau-docs'
# → copy chuỗi $2a$14$... vào .env bước dưới
# 2. Set env trong .env (root, compose đọc qua env_file)
cat >> .env <<'EOF'
ACME_EMAIL=ops@glamvoo.com
DOCS_BASIC_AUTH_USER=admin
DOCS_BASIC_AUTH_HASH=$2a$14$.... # hash từ bước 1 (escape $ nếu shell nuốt — bọc 'single quote' khi set thủ công)
# Chỉ cần nếu hỗ trợ APEX custom domain (vd mysalon.com trần). Subdomain (book.mysalon.com) KHÔNG cần.
CUSTOM_DOMAIN_SERVER_IPS=<IP-edge>
EOF
# 3. DNS: tạo A record để tenant CNAME/ALIAS trỏ tới (đích "connect")
# connect.glamvoo.com A <IP-edge>
# KHUYẾN NGHỊ: <IP-edge> là Floating/Elastic IP, KHÔNG phải IP raw VPS — xem custom-domain.md §12 (scaling).
# 4. Validate Caddyfile trước khi chạy (tùy chọn, an toàn)
docker run --rm -e ACME_EMAIL -e DOCS_BASIC_AUTH_USER -e DOCS_BASIC_AUTH_HASH \
-v "$PWD/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2-alpine \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# 5. Khởi động (kéo image + chạy toàn stack; Caddy tự xin cert platform domain)
docker compose -f docker-compose.prod.yml up -d
Verify Caddy đã lên + cấp cert:
docker compose -f docker-compose.prod.yml ps caddy # State = healthy
docker compose -f docker-compose.prod.yml logs -f caddy # tìm "certificate obtained successfully"
curl -I https://glamvoo.com # 200 + cert hợp lệ
Cutover từ nginx (nếu đang chạy nginx cũ): chạy
./deploy.shnhư bình thường — bước 2.5 dùngdocker compose up -d --remove-orphansnên tự gỡ container nginx cũ (nó đang giữ port 80/443) trước khi đưa caddy lên. Không cần thao tác tay. Cert Certbot cũ KHÔNG cần migrate (Caddy xin mới). Sau khi ổn, dừngsudo systemctl disable --now certbot.timer. Cert Caddy lưu volumecaddy_data— đừng xoá (re-issue dính rate-limit Let's Encrypt).Deploy thường ngày:
./deploy.shpull image mới + rolling restart api + reload caddy config (caddy reload, zero-downtime). Caddyfile đổi → bước 6 tự reload.
Đổi domain khác (không phải glamvoo.com): sửa
server_nametrongcaddy/Caddyfile(các blockglamvoo.com,www.,images.,docs.) →docker compose restart caddy.
Kết nối custom domain cho tenant + smoke test: xem ../architecture/custom-domain.md §11. Debug: troubleshooting.md §Custom domain.
Rollback Caddy → nginx (khi cutover lỗi)
Rollback CHỈ đụng repo deploy (
/opt/booking-system= booking-docs) — nơi chứadocker-compose.prod.yml+deploy.sh+caddy/. KHÔNG đụng commit của api/web: image của chúng chỉ là code app, không chứa config edge, vẫn chạy nguyên khi rollback.
Caddy thay nginx không phá huỷ gì của nginx cũ → quay lại được dễ. Điều kiện để rollback luôn khả thi (giữ trong giai đoạn cutover):
- KHÔNG xoá
/etc/letsencrypt— cert Certbot cũ phải còn (compose mới không mount nhưng không xoá; giữ nguyên trên host). - KHÔNG tắt
certbot.timervội — giữ chạy vài ngày đến khi Caddy proven. Cert LE sống 90 ngày; rollback trong thời gian đó thì cert nginx còn hạn. Chỉsudo systemctl disable --now certbot.timerSAU khi chắc Caddy ổn định. - File
nginx/nginx.conf+nginx/conf.d/booking.confvẫn trong repo (deprecated, không xoá) → nginx dựng lại được ngay. - Migration DB là additive (chỉ thêm bảng
tenant_domains) → KHÔNG cần rollback DB.
Thao tác rollback (downtime ~1-2 phút):
cd /opt/booking-system
# Tìm commit đưa Caddy vào repo deploy (khỏi nhớ SHA):
git log --oneline | grep -i caddy # vd: b494ea1 feat(custom-domain): Caddy TLS edge
# Lấy lại compose + deploy.sh bản TRƯỚC commit đó (không reset, không phá git history).
# <caddy-commit>~1 = commit ngay trước commit Caddy → lúc đó 2 file vẫn là bản nginx.
git checkout b494ea1~1 -- docker-compose.prod.yml deploy.sh
# Gỡ caddy + dựng lại nginx (nginx đọc cert /etc/letsencrypt cũ).
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Verify
curl -I https://glamvoo.com # 200 + cert hợp lệ (cert certbot cũ)
docker compose -f docker-compose.prod.yml ps
Sau khi nginx chạy lại ổn, điều tra log Caddy (docker compose logs caddy từ lần lỗi) để fix rồi thử cutover lại. Khi đã quyết định bỏ rollback hẳn (Caddy ổn lâu dài): git checkout -- docker-compose.prod.yml deploy.sh để về lại bản Caddy.
(Deprecated) TLS certificates qua certbot host-level — chỉ dùng nếu rollback về nginx
Certs mount read-only vào nginx container, renewal qua cron của host.
sudo apt install -y certbot
# Tạo webroot dir 1 lần (certbot ghi file challenge, nginx serve qua /.well-known/)
sudo mkdir -p /var/www/certbot
# Lần đầu (nginx CHƯA chạy — vd lần đầu setup VPS):
sudo certbot certonly --standalone -d glamvoo.com -d www.glamvoo.com
sudo certbot certonly --standalone -d images.glamvoo.com
sudo certbot certonly --standalone -d docs.glamvoo.com
# SAU KHI nginx Docker đã chạy — DÙNG WEBROOT (zero-downtime):
sudo certbot certonly --webroot -w /var/www/certbot -d <new-subdomain>
# Convert cert hiện tại (issued via --standalone) sang webroot mode để
# certbot.timer auto-renew không phải stop nginx:
sudo certbot certonly --webroot -w /var/www/certbot \
-d glamvoo.com -d www.glamvoo.com --force-renewal
sudo certbot certonly --webroot -w /var/www/certbot \
-d images.glamvoo.com --force-renewal
sudo certbot certonly --webroot -w /var/www/certbot \
-d docs.glamvoo.com --force-renewal
# (Optional) tách subdomain riêng cho API sau này:
# sudo certbot certonly --webroot -w /var/www/certbot -d api.glamvoo.com
# Auto-renew (sẵn có cron của certbot)
sudo systemctl status certbot.timer
2.5 Clone repo + GHCR auth
# Repo deploy stack (compose, caddy/Caddyfile, deploy.sh) — KHÔNG phải app code
sudo mkdir -p /opt/booking-system && sudo chown $USER /opt/booking-system
cd /opt/booking-system
git clone git@github.com:novagoo/booking-docs.git .
# Login GHCR (PAT từ GitHub Settings → Developer settings → PAT classic, scope `read:packages`)
echo $GHCR_PAT | docker login ghcr.io -u <github-username> --password-stdin
# Cấu hình env
cp .env.example .env
# Sửa: DATABASE_URL, JWT_SECRET, NEXT_PUBLIC_*, BULL_BOARD_*, SMTP_*, PAYMENT_ENCRYPTION_KEY…
# Cấu hình nginx — sửa server_name + cert path trong nginx/conf.d/booking.conf
2.6 Storage (S3-compatible)
Dev hiện tại dùng VNPT S3 (vnpt-s3.gdata.com.vn). Production khuyến nghị dùng cùng provider, bucket riêng để cô lập dev/prod:
# .env
STORAGE_ENDPOINT=https://vnpt-s3.gdata.com.vn
STORAGE_REGION=auto
STORAGE_ACCESS_KEY=<từ VNPT console>
STORAGE_SECRET_KEY=<từ VNPT console>
STORAGE_BUCKET=glamvoo-prod # tạo bucket mới trên VNPT console
STORAGE_FORCE_PATH_STYLE=true # VNPT/MinIO/Garage cần true
STORAGE_PUBLIC_BASE= # để trống = fallback STORAGE_ENDPOINT
Imgproxy keys — production phải khác dev (cô lập signed URL):
openssl rand -hex 32 # → IMGPROXY_KEY trong .env
openssl rand -hex 32 # → IMGPROXY_SALT
| Provider khác (tương lai) | Khi cần | Note |
|---|---|---|
| Cloudflare R2 | Egress 0đ + CDN built-in | FORCE_PATH_STYLE=false |
| Hetzner Object Storage | EU compliance (Na Uy) | FORCE_PATH_STYLE=false |
| MinIO Docker | Self-host hoàn toàn | Thêm service vào compose, volume riêng |
Provider-specific config xem docs/architecture/upload-architecture.md §11.
3. Deploy flow
a. Build (tự động qua GitHub Actions)
- Mỗi push lên
maincủabooking-apihoặcbooking-web→ GHA build image, push GHCR với 2 tag:ghcr.io/novagoo/booking-api:latestghcr.io/novagoo/booking-api:sha-<git-sha>
- Cache GHA persist giữa các build → build sau ~1–2 phút.
b. Deploy (manual trigger trên server)
ssh server
cd /opt/booking-system
./deploy.sh
deploy.sh chạy:
git pull— refresh compose/nginx config (KHÔNG phải app code, code nằm trong image).docker compose pull api web— kéo image mới.prisma migrate deploy— chạy 1-shot container, migration phải backward-compatible với version đang serve traffic.- Rolling update api:
- Scale lên
2 × API_REPLICAS(warm new replicas). - Đợi ≥
API_REPLICAScontainers báo healthy (timeout 120s). - Scale về
API_REPLICAS(Docker drop replicas cũ).
- Scale lên
- Restart web (single replica → ~5–10s blip).
- Reload nginx config nếu thay đổi.
- Prune dangling images.
c. Pin specific version
IMAGE_TAG=sha-abc123def ./deploy.sh # pin một build cụ thể
4. Scale up / down
# Scale ngay không qua deploy.sh
API_IMAGE=ghcr.io/novagoo/booking-api:latest \
WEB_IMAGE=ghcr.io/novagoo/booking-web:latest \
docker compose -f docker-compose.prod.yml up -d --no-deps --scale api=4 api
# Hoặc qua biến trong deploy
API_REPLICAS=4 ./deploy.sh
Nginx (Docker DNS resolver, valid=30s) tự pick replica mới trong 30s. Không cần reload.
5. Rollback
# Lấy SHA tag cần rollback từ GHCR
IMAGE_TAG=sha-<previous-good-sha> ./deploy.sh
Image cũ vẫn còn local nếu chưa prune → rollback ~30s. Lưu ý: nếu version mới đã chạy migration không backward-compatible, rollback sẽ fail. Đó là lý do migration luôn phải additive (xem §6).
6. Migration policy (BẮT BUỘC)
Để rolling update zero-downtime hoạt động, mọi migration phải backward-compatible với version đang chạy:
| Thay đổi | Cách làm |
|---|---|
| Thêm cột | Nullable hoặc có DEFAULT → deploy 1 |
NOT NULL |
Deploy 1: thêm nullable + backfill. Deploy 2: ALTER NOT NULL. |
| Đổi tên cột | Deploy 1: thêm cột mới + dual-write. Deploy 2: code đọc cột mới. Deploy 3: drop cột cũ. |
| Drop cột | Deploy 1: code ngừng dùng. Deploy 2: drop. |
| Đổi type | Tương tự đổi tên — qua cột tạm. |
Không bao giờ chạy migration phá schema mà replicas cũ phụ thuộc vào.
7. Healthcheck & rate limit chạy đúng
/api/health— kiểm Postgres + Redis. 503 khi degraded → Docker mark unhealthy → nginx Docker DNS skip replica đó.- TRUST_PROXY=1 — set trong
docker-compose.prod.ymlenv. Override mặc địnhloopbackvì traffic đi qua bridge network (172.x), không phải 127.x. Throttler dùngreq.ipđúng client thật, không phải IP nginx container. - Redis SHARED —
REDIS_HOST=redistrong env, mọi replica connect cùng instance → counters cộng dồn đúng. Restart Redis = mất counter (TTL 60s, chấp nhận được).
8. Logs & debugging
# Logs tất cả services, follow
docker compose -f docker-compose.prod.yml logs -f
# Logs riêng api (tất cả replicas)
docker compose -f docker-compose.prod.yml logs -f api
# Vào container để debug
docker compose -f docker-compose.prod.yml exec api sh
# Test healthcheck từ host
curl -i http://localhost/api/health # qua nginx
docker compose -f docker-compose.prod.yml exec api curl -i http://localhost:3010/api/health # trực tiếp
# Xem replicas đang chạy
docker compose -f docker-compose.prod.yml ps
9. Common pitfalls
| Triệu chứng | Nguyên nhân | Fix |
|---|---|---|
connection refused đến Postgres từ container |
pg_hba.conf chưa whitelist 172.16.0.0/12 |
Sửa pg_hba.conf + restart postgres |
| Throttler block toàn bộ user sau 1 spammer | TRUST_PROXY vẫn là loopback |
Đảm bảo env có TRUST_PROXY=1 |
| Rate limit không enforce sau scale | Mỗi replica có Redis riêng (sai cấu hình) | REDIS_HOST=redis trên mọi replica, KHÔNG localhost |
| 502 sau deploy | API chưa healthy → nginx vẫn route vào | Đợi healthcheck pass trước khi route; deploy.sh đã handle |
| Disk đầy sau vài deploy | Image cũ tích tụ | Cron docker image prune -af --filter "until=168h" weekly |
| Nginx không pick replica mới | Resolver cache TTL | Đã set valid=30s — đợi 30s, hoặc nginx -s reload |
10. Multi-VPS (tương lai)
Khi cần scale ra >1 VPS, có 2 hướng:
a. Docker Swarm (1 ngày setup) — docker swarm init + docker stack deploy. Tận dụng compose file hiện tại với deploy.replicas, có rolling update built-in, service mesh DNS chéo node.
b. Kubernetes (1 tuần setup) — k3s hoặc managed (Hetzner Cloud K8s). Overhead cao hơn nhưng auto-scale theo metrics.
Setup hiện tại (1 VPS, plain compose) đủ dùng đến ~50–100 salons. Khi vượt → migrate, KHÔNG vá thêm.
11. Ports (internal-only trừ nginx)
| Service | Port | Access |
|---|---|---|
| Nginx HTTP | 80 | Public (redirect → 443) |
| Nginx HTTPS | 443 | Public |
| API (NestJS) | 3010 | Container only — qua Docker DNS api:3010 |
| Web (Next.js) | 3000 | Container only — qua Docker DNS web:3000 |
| Redis | 6379 | Container only — qua Docker DNS redis:6379 |
| PostgreSQL | 5432 | Host loopback + 172.17.0.1 (Docker bridge) |
UFW recommended:
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Cho phép Docker bridge reach Postgres (KHÔNG explicit deny 5432 Anywhere
# — sẽ match TRƯỚC rule allow specific subnet và block luôn).
sudo ufw allow from 172.16.0.0/12 to any port 5432 proto tcp
sudo ufw enable
Default policy của UFW đã
deny incoming, nên KHÔNG cần thêmufw deny 5432/tcp. Nếu thêm, nó sẽ block cả traffic từ Docker bridge vì rule order top-to-bottom,Anywherematch trước subnet specific.
12. Bull Board (queue admin)
/api/queues được protect bằng Basic Auth (set BULL_BOARD_USER / BULL_BOARD_PASS trong .env). Truy cập qua:
https://glamvoo.com/api/queues
Cross-references
deploy-docs-site.md— Deploy trang docsdocuments.<domain>(riêng, không liên quan app chính).troubleshooting.md— Debug runbook chung.booking-api/Dockerfile— Multi-stage build cóprismaCLI cho migrate.booking-web/Dockerfile— Next standalone output.docker-compose.prod.yml— Stack definition.deploy.sh— Pipeline đã document ở §3.