Native Deploy (SSH + pm2 + Caddy) - Design

  • Issue: #221
  • Date: 2026-07-13
  • Status: Approved (design)

1. Bối cảnh & mục tiêu

Hiện production deploy bằng Docker: build image (docker/*.Dockerfile) → push ghcr.io → SSH pull + docker compose -f docker-compose.prod.yml up. Caddy, backend, frontend, docs, Postgres, Redis, Portainer đều là container.

Mục tiêu: chuyển app (backend + frontend + docs) sang chạy native trên server (SSH + git pull + build tại chỗ + pm2 + Caddy native), giữ Docker cho infra (Postgres/TimescaleDB, Redis, ThingsBoard). Đơn giản hoá vận hành, bỏ tầng image registry cho app.

Ngoài phạm vi (out of scope): mobile, gateway firmware, CI pipeline, thay đổi logic ứng dụng.

2. Quyết định đã chốt

Chủ đề Quyết định
Phạm vi native Chỉ app (backend/frontend/docs). Infra giữ Docker (docker-compose.infra.yml).
Build/delivery git pull + build trên server.
Reverse proxy/TLS Caddy cài native (systemd), auto-HTTPS Let’s Encrypt.
Serve static Caddy file_server phục vụ thẳng frontend/distdocs-website/site.
Process manager pm2, backend chạy bằng bun, fork mode 1 instance.
File Docker cũ Xóa các file Docker production. Giữ infra + dev Dockerfiles.

3. Kiến trúc runtime

                    Internet (80/443)
                          │
                    ┌─────▼─────┐   Caddy native (systemd)
                    │   Caddy   │   auto-HTTPS
                    └──┬────┬───┘
   /api,/ws → :3000    │    │  / → file_server
              ┌────────▼─┐  ├──────────────► frontend/dist       (SPA static)
   pm2 (bun)→ │ backend  │  └──────────────► docs-website/site   (static)
              └────┬─────┘
                   │ localhost
   Docker infra (docker-compose.infra.yml):
     Postgres/TimescaleDB (5432) · Redis (6379) · ThingsBoard (8090 REST/WS, 1883 MQTT)

Chỉ backend là process do pm2 quản lý. Frontend/docs là file tĩnh do Caddy phục vụ (không cần process riêng).

4. Thành phần chi tiết

4.1 Backend dưới pm2

Facts:

  • nest builddist/src/main.js; app listen PORT (default 3000).
  • ConfigModule.forRoot({ envFilePath: '.env' }) → đọc .env theo cwd của backend.
  • migration:run khi có bản dist dùng bunx typeorm migration:run -d dist/src/database/migrations.js.

deploy/ecosystem.config.cjs:

  • 1 app hmpiot-backend
  • cwd: đường dẫn thư mục backend
  • script: dist/src/main.js
  • interpreter: bun (bun trong PATH)
  • exec_mode: fork, instances: 1 (an toàn cho WebSocket /ws + subscription MQTT in-memory; cluster sẽ gây lệch state)
  • env: NODE_ENV=production
  • autorestart: true, giới hạn restart hợp lý

Boot persistence: pm2 startup + pm2 save.

4.2 Env - single source of truth

  • 1 file .env.production ở root server (không commit; đã có .env.production.example).
  • Backend: symlink backend/.env -> ../.env.production (vì ConfigModule đọc .env). Tạo/refresh symlink trong deploy script.
  • Frontend: symlink frontend/.env.production -> ../.env.production. vite build mặc định mode production sẽ tự load file này qua dotenv parser (xử lý đúng giá trị có dấu cách như VITE_APP_NAME=HMP IoT Platform) và chỉ expose biến VITE_* ra client. Tránh source .env.production vì sẽ vỡ với value không quote có dấu cách.

4.3 Caddy native (/etc/caddy/Caddyfile)

  • app.iot.farmvion.com:
    • handle /api/*, handle /ws/*, handle /apireverse_proxy localhost:3000 (giữ header up như hiện tại; /ws giữ Connection/Upgrade).
    • handle {}root * <path>/frontend/dist + file_server + try_files {path} /index.html (SPA fallback).
    • Security headers + gzip/zstd giữ như bản Docker.
  • docs.iot.farmvion.comroot * <path>/docs-website/site + file_server.
  • Bỏ block portaineremqx (chỉ dành cho Docker).
  • (Tùy chọn) tb.iot.farmvion.comreverse_proxy localhost:8090 cho dashboard ThingsBoard.

4.4 Deploy flow - deploy/scripts/deploy.sh (chạy trên server)

1. kiểm tra .env.production tồn tại
2. git pull (branch hiện tại / master)
3. bun install (frozen lockfile)
4. refresh symlink backend/.env -> ../.env.production
5. backend:  bun run build
6. frontend: (source env) bun run build
7. docs:     bun run build
8. backend:  bun run migration:run
9. pm2 reload deploy/ecosystem.config.cjs  (hoặc start nếu chưa chạy)
10. caddy validate + reload nếu Caddyfile đổi

Idempotent: chạy lại nhiều lần an toàn. Lỗi ở bước build/migration thì dừng (set -e), không reload pm2.

4.5 Provisioning lần đầu - deploy/scripts/server-setup.sh

Cài + cấu hình:

  • Docker + compose plugin (chỉ để chạy infra).
  • bun (curl installer), Node.js (đủ cho pm2 CLI), pm2 (global).
  • Caddy (apt repo chính thức), enable systemd.
  • Khởi động docker compose -f docker-compose.infra.yml up -d.
  • Hướng dẫn tạo .env.production từ example; pm2 startup.

Firewall: 22, 80, 443, 1883 (MQTT). Không cần mở 5432/6379/8090 ra ngoài.

4.6 Dọn dẹp file Docker production

Xóa:

  • docker-compose.prod.yml
  • deploy.sh (root, luồng ghcr.io), release.sh, build.sh
  • .vps.config, .vps.config.example
  • docker/backend.Dockerfile, docker/frontend.Dockerfile, docker/docs.Dockerfile (production)
  • docker/nginx.conf, docker/nginx-frontend.conf, docker/nginx-docs.conf (chỉ dùng cho prod image, Caddy nay serve static)
  • caddy/Caddyfile (bản trùng, không được tham chiếu ở đâu)
  • Makefile targets build-containers, release

Giữ:

  • docker-compose.infra.yml, docker-compose.yml (dev) và docker/*.dev.Dockerfile, docker/backend.dev.start.sh, .dockerignore
  • deploy/Caddyfile, deploy/scripts/* (viết lại), deploy/scripts/backup.sh (pg_dump qua docker exec vẫn hợp lệ vì infra còn Docker)

5. Data flow (deploy 1 lần)

  1. Dev SSH lên server, vào REMOTE_APP_DIR.
  2. deploy/scripts/deploy.sh → pull + build + migrate + pm2 reload.
  3. Caddy (đang chạy) phục vụ static mới + proxy tới backend đã reload.
  4. Không downtime backend nhờ pm2 reload (graceful).

6. Xử lý lỗi

  • deploy.sh set -e: build/migration fail → dừng trước khi reload, giữ bản đang chạy.
  • pm2 reload graceful; nếu backend crash-loop, pm2 giữ log, autorestart có giới hạn.
  • Caddy caddy validate trước reload; config lỗi → không reload, giữ cấu hình cũ.
  • Infra Docker restart: unless-stopped tự lên lại sau reboot; pm2 startup+save; Caddy systemd enabled.

7. Kiểm thử / nghiệm thu

Vì đây là thay đổi hạ tầng (script/config), không phải code app, “test” ở đây là kiểm chứng vận hành:

  • bunx @biomejs/biome không áp cho shell/Caddyfile; đảm bảo không phá lint TS hiện có.
  • deploy/scripts/deploy.sh chạy được với bash -n (syntax) và dry-run ở môi trường staging.
  • caddy validate --config deploy/Caddyfile.
  • Nghiệm thu E2E trên staging: HTTPS lên, /api/v1 trả 200, đăng nhập được, WebSocket /ws kết nối, docs mở được, backend tự lên lại sau reboot.

8. Rủi ro & lưu ý

  • Build trên server tốn RAM/CPU lúc deploy (frontend Vite build) — server 4-8GB đủ nhưng nên deploy lúc traffic thấp.
  • Cần cả bun (build+runtime) và node (pm2 CLI) trên server.
  • Symlink backend/.env phải được refresh mỗi deploy (git pull không đụng file untracked nhưng symlink cần đảm bảo tồn tại).
  • ThingsBoard first boot mất vài phút (đã ghi chú trong infra compose).