GĐ15 — DevOps & vận hành: Docker, CI/CD, deploy

Study note cho FE engineer (mạnh JS/TS) chuyển sang Backend. Mục tiêu: tự đóng gói, tự deploy, tự debug prod mà không phải chờ DevOps.

Kiểm chứng ngày 2026-10-05: Node 24 là LTS khuyến nghị (Node 20 đã EOL 2026-04-30) nên Dockerfile dùng node:24-alpine; Vercel Functions với Fluid compute mặc định 300 s (tối đa 800 s ở Pro/Enterprise); các workflow ở mục 8 và ví dụ deploy ECS ở cuối stage dùng actions/checkout@v7, actions/setup-node@v7 (Node 24), aws-actions/configure-aws-credentials@v6, docker/build-push-action@v7. Image dữ liệu: postgres:18-alpine (volume ở /var/lib/postgresql) và redis:8-alpine, tag đối chiếu trên Docker Hub (postgres, redis). Vòng đời chứng chỉ TLS ở mục 10 theo hai lịch riêng (CA/B Forum và Let's Encrypt). Ở lần cập nhật này daemon Docker không chạy: các lệnh docker, docker compose và workflow mới thêm là code tham chiếu, chưa chạy; chỗ nào đã chạy thật thì có ghi "Đã chạy".


1. Vì sao backend dev cần biết DevOps cơ bản#

Định nghĩa. DevOps là tập practice ghép "Development" và "Operations": code, đóng gói, ship, chạy, giám sát — coi tất cả như một vòng đời liên tục thay vì "dev viết xong ném cho ops".

Tại sao quan trọng. Ở FE bạn npm run build ra file tĩnh, đẩy lên CDN là xong. Backend thì khác: service phải chạy liên tục, giữ kết nối DB, xử lý concurrency, có thể crash lúc 2h sáng. Nếu bạn không hiểu môi trường chạy, mỗi bug prod bạn đều phải chờ người khác. Team nhỏ / startup thường không có DevOps riêng — backend dev tự lo luôn.

Cơ chế (những việc thực tế bạn sẽ làm).

  • Đóng gói app thành artifact chạy được ở mọi máy (Docker image).
  • Định nghĩa pipeline: commit → test → build → deploy tự động (CI/CD).
  • Cấu hình môi trường (env vars, secrets) tách khỏi code.
  • Đọc log prod, kiểm tra health, rollback khi hỏng.

Ví dụ ngắn. Vòng lặp tối thiểu bạn cần tự chạy được:

textReady
git push → CI chạy test → build Docker image → deploy → health check OK

Pitfall / case thực tế. "Chạy được trên máy tôi" là câu nói kinh điển gây cháy. Nguyên nhân gần như luôn là môi trường lệch: Node version khác, thiếu env var, thiếu system lib. DevOps cơ bản (Docker + env chuẩn) chính là để diệt câu này. Bug prod đầu đời của bạn thường không phải bug logic mà là bug cấu hình môi trường — biết đọc log và biết env đang là gì sẽ cứu bạn.


2. Container vs VM — Docker là gì, image vs container#

Định nghĩa.

  • VM (Virtual Machine): ảo hóa cả phần cứng — mỗi VM chạy một OS đầy đủ (kernel riêng). Nặng (GB), boot chậm (phút).
  • Container: ảo hóa ở tầng OS — nhiều container dùng chung kernel của host, chỉ cô lập process, filesystem, network. Nhẹ (MB), start trong mili giây.
  • Docker: công cụ phổ biến nhất để build và chạy container.

Tại sao quan trọng. Container cho bạn "đóng gói cả môi trường" (Node runtime + code + system deps) thành một artifact bất biến. Ship image đó đi đâu cũng chạy giống hệt → diệt "works on my machine". Nhẹ hơn VM nên chạy được nhiều service/1 máy, scale nhanh.

Cơ chế.

  • Image: template bất biến, read-only, gồm nhiều layer xếp chồng (mỗi lệnh Dockerfile = 1 layer). Giống "class" trong OOP.
  • Container: một instance đang chạy của image, có thêm một lớp writable ở trên. Giống "object". Từ 1 image chạy được N container.
  • Layer được cache và share giữa các image → tiết kiệm disk và thời gian build.

Ví dụ ngắn.

bashReady
docker build -t myapi:1.0 .     # tạo image từ Dockerfiledocker run -p 3000:3000 myapi:1.0   # chạy 1 container từ imagedocker ps                        # xem container đang chạy

Pitfall / case thực tế.

  • Nhầm image vs container: sửa file trong container đang chạy rồi docker rm → mất sạch, vì layer writable không được lưu vào image. Muốn lưu phải docker commit (không nên) hoặc sửa Dockerfile rồi build lại (đúng).
  • Container không phải VM: nó chia sẻ kernel host. Image Linux chạy trên macOS/Windows thực chất chạy trong một Linux VM ẩn (Docker Desktop) → nhớ khi debug performance.

3. Dockerfile cho Node#

Định nghĩa. Dockerfile là script khai báo cách build một image: base image nào, copy gì, chạy lệnh gì, mở port nào, start ra sao.

Tại sao quan trọng. Đây là "recipe" quyết định image nặng/nhẹ, build nhanh/chậm, an toàn/không. Viết sai thứ tự → mỗi lần đổi 1 dòng code phải cài lại toàn bộ node_modules (chậm khủng khiếp trong CI).

Cơ chế (điểm mấu chốt: layer caching). Docker cache theo từng lệnh. Nếu input của một lệnh không đổi so với lần build trước, nó reuse cache. Cache bị "vỡ" từ lệnh đầu tiên có thay đổi trở xuống. → Đặt phần ít thay đổi (dependencies) TRƯỚC phần hay thay đổi (source code).

Ví dụ ngắn.

textReady
# base image nhỏ, pin version cụ thểFROM node:24-alpineWORKDIR /app# copy manifest TRƯỚC để tận dụng cache layerCOPY package.json package-lock.json ./RUN npm ci                 # cài đúng theo lock, sạch, nhanh, reproducible# copy source SAU — đổi code không phá cache của npm ciCOPY . .RUN npm run build          # tsc → dist/ (dist bị .dockerignore loại nên phải build trong image)# tài liệu hóa port (không tự publish)EXPOSE 3000# lệnh chạy khi container startCMD ["node", "dist/server.js"]

Dockerfile chỉ coi # là comment khi nó đứng đầu dòng; viết # sau một lệnh (trừ RUN, nơi shell tự bỏ qua) thì nó thành đối số, nên các comment ở EXPOSE/CMD được đặt trên dòng riêng.

Giải thích từng phần.

  • node:24-alpine: Alpine là distro Linux siêu nhẹ (~5MB base) → image nhỏ. Pin major (24, dòng LTS) thay vì latest để build reproducible.
  • RUN npm run build: image chỉ chạy được dist/server.js sau khi compile. .dockerignore (mục 5) loại dist khỏi build context nên dist trên máy bạn không vào image; thiếu bước này container thoát ngay với Cannot find module '/app/dist/server.js'. Ví dụ này còn nguyên devDependencies; mục 4 tách chúng ra khỏi image cuối.
  • npm ci thay vì npm install: ci cài chính xác theo package-lock.json, xóa node_modules cũ trước, nhanh và deterministic — chuẩn cho CI/Docker.
  • EXPOSE: chỉ là metadata/tài liệu, KHÔNG mở port ra ngoài. Muốn truy cập vẫn phải -p 3000:3000 khi docker run.
  • CMD dạng exec (["node", ...]) tốt hơn dạng shell (node ...): process Node là PID 1 và nhận SIGTERM trực tiếp. Nhận được chưa có nghĩa là xử lý đúng: PID 1 không có handler thì SIGTERM bị bỏ qua (xem mục 12), nên app vẫn phải tự lắng nghe SIGTERM mới graceful shutdown được.

Pitfall / case thực tế.

  • COPY . . TRƯỚC npm ci → mỗi lần đổi 1 dòng code, cache npm ci vỡ, cài lại toàn bộ deps. Đây là lỗi #1 làm CI chậm.
  • Alpine dùng musl libc thay vì glibc → vài native module (bcrypt, sharp, node-gyp) build lỗi. Khi đó dùng node:24-slim (Debian) hoặc cài build tools.
  • Dùng CMD npm start (dạng shell) → npm là PID 1, nuốt signal → container không shutdown gọn, deploy bị treo.

4. Multi-stage build#

Định nghĩa. Một Dockerfile chứa nhiều FROM (nhiều "stage"). Stage build dùng để compile/bundle; stage runtime chỉ copy kết quả sang, bỏ hết công cụ build.

Tại sao quan trọng. Image production nên chỉ chứa thứ cần để chạy, không cần TypeScript compiler, devDependencies, source .ts, test... Multi-stage giảm size (vài trăm MB → vài chục MB), giảm bề mặt tấn công (ít package = ít CVE), và không vô tình ship secret/dev tool.

Cơ chế. Mỗi stage có filesystem riêng. COPY --from=<stage> bốc artifact từ stage trước. Image cuối cùng = chỉ stage cuối; các stage trung gian bị bỏ.

Ví dụ ngắn.

textReady
# ---- stage 1: build ----FROM node:24-alpine AS builderWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci                     # cả devDependencies (tsc, types...)COPY . .RUN npm run build              # tsc → dist/# ---- stage 2: runtime ----FROM node:24-alpineWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=dev          # CHỈ prod deps# chỉ lấy output của stage buildCOPY --from=builder /app/dist ./distEXPOSE 3000CMD ["node", "dist/server.js"]

Pitfall / case thực tế.

  • Quên --omit=dev ở stage runtime → vẫn ship typescript, @types/*, jest... image phình to.
  • Copy nhầm node_modules từ builder (có cả devDeps) sang runtime → mất tác dụng multi-stage. Nên copy dist/ và cài lại prod deps riêng.
  • Native module cần compile: nếu build ở builder rồi copy node_modules sang runtime, phải chắc hai stage cùng OS/arch, nếu không binary không tương thích.

Cache mount, secret mount và quét lỗ hổng cho image#

Ba thứ này bổ sung cho multi-stage, không thay nó. Code dưới đây là code tham chiếu, chưa chạy (lần soát này không có daemon Docker).

textReady
# syntax=docker/dockerfile:1FROM node:24-alpine AS builderWORKDIR /appCOPY package.json package-lock.json ./# cache: thư mục cache npm sống ngoài layer; secret: .npmrc chỉ hiện trong lệnh nàyRUN --mount=type=cache,target=/root/.npm \    --mount=type=secret,id=npmrc,target=/root/.npmrc \    npm ciCOPY . .RUN npm run build
bashReady
docker build --secret id=npmrc,src="$HOME/.npmrc" -t app:ci .
  • Cache mount (--mount=type=cache,target=/root/.npm): /root/.npm là chỗ npm cache mặc định, mount này giữ nó giữa các lần build mà không ghi vào layer (Docker: tối ưu cache, đọc 2026-10-05). Runner CI mới mỗi lần thì mount này trống nếu không có nơi lưu cache của BuildKit; cách cấu hình chưa xác minh.
  • Secret mount (--mount=type=secret,id=...): mặc định file nằm ở /run/secrets/<id>, target đổi đường dẫn. Secret chỉ tồn tại trong lệnh RUN đó và không vào image cuối, khác ARG/ENV vốn còn lại trong image (Docker: build secrets, đọc 2026-10-05). Chỉ cần khi npm ci kéo package từ registry riêng; stage runtime chạy npm ci --omit=dev cũng cần mount tương tự.
  • Quét lỗ hổng: docker scout cves --only-severity critical,high --exit-code app:ci chỉ liệt kê mức critical và high; --exit-code trả mã 2 khi có lỗ hổng nên CI fail được (Docker Scout CLI, đọc 2026-10-05). Mã thoát khi kết hợp hai cờ này chưa xác minh: chạy thử một lần trong CI trước khi dùng nó làm cổng chặn.

5. .dockerignore#

Định nghĩa. File liệt kê những gì KHÔNG copy vào build context (tương tự .gitignore).

Tại sao quan trọng. COPY . . copy toàn bộ thư mục. Nếu không loại trừ, node_modules local (thường vài trăm MB, còn có thể là binary sai OS) và .git, .env bị đẩy vào image → build chậm, image to, LỘ SECRET.

Cơ chế. Docker đọc .dockerignore khi tính build context (thứ gửi tới daemon). File match bị loại trước cả khi COPY chạy → build nhanh hơn và cache ổn định hơn.

Ví dụ ngắn.

textReady
node_modulesnpm-debug.log.git.env.env.*distcoverageDockerfile.dockerignore

Pitfall / case thực tế.

  • Không có .dockerignore → copy node_modules local (macOS binary) vào image Linux → app crash runtime với lỗi native module. Đây là bẫy kinh điển của người mới.
  • Quên ignore .env → secret đi thẳng vào image layer, ai docker history / pull image đều đọc được. Secret trong layer KHÔNG xóa được bằng cách xóa file ở layer sau.

6. docker-compose — nhiều service#

Định nghĩa. docker-compose (nay là docker compose) khai báo và chạy nhiều container liên quan bằng một file YAML: app + database + cache + queue... với một lệnh docker compose up.

Tại sao quan trọng. App backend hiếm khi đứng một mình — nó cần Postgres, Redis... Compose cho bạn dựng nguyên stack local giống prod, reproducible, share được cho cả team (chỉ cần git clone + docker compose up). Thay thế cho "cài Postgres thủ công lên máy" (mỗi người một version, hỏng lung tung).

Cơ chế.

  • services: mỗi service = 1 container (hoặc scale nhiều).
  • networks: compose tự tạo network chung; các service gọi nhau bằng tên service làm hostname (DNS nội bộ). App connect Postgres qua postgres:5432, không phải localhost.
  • volumes: mount dữ liệu bền (DB data) ra ngoài container để không mất khi container bị xóa.
  • depends_on: thứ tự khởi động (chỉ đảm bảo start trước, KHÔNG đảm bảo ready).
  • environment / env_file: truyền env vào container.

Ví dụ ngắn.

textReady
services:  app:    build: .    ports:      - "3000:3000"    environment:      DATABASE_URL: postgres://user:pass@postgres:5432/app      REDIS_URL: redis://redis:6379    depends_on:      postgres:        condition: service_healthy   # chờ DB healthy, không chỉ started  postgres:    image: postgres:18-alpine    environment:      POSTGRES_USER: user      POSTGRES_PASSWORD: pass      POSTGRES_DB: app    volumes:      - pgdata:/var/lib/postgresql   # data bền; image 18 đặt PGDATA ở /var/lib/postgresql/18/docker    healthcheck:      test: ["CMD-SHELL", "pg_isready -U user"]      interval: 5s      timeout: 3s      retries: 5  redis:    image: redis:8-alpinevolumes:  pgdata:

Image Postgres 18 đổi đường dẫn volume. Từ PostgreSQL 18, image chính thức đặt PGDATA theo phiên bản (/var/lib/postgresql/18/docker) và khai báo VOLUME là /var/lib/postgresql, không còn /var/lib/postgresql/data như bản 17 trở xuống (trang image, đọc ngày 2026-10-05). Trang image yêu cầu mount theo vị trí mới. Suy luận từ khai báo VOLUME (chưa chạy vì máy soạn không có Docker daemon): mount pgdata vào đường dẫn cũ /var/lib/postgresql/data sẽ không chứa PGDATA của image 18, nên dữ liệu có thể nằm ở volume ẩn danh và mất khi docker compose down rồi up. Volume pgdata đã tạo bởi image 16 cũng không tự nâng lên 18: ở lab thì xoá volume (docker compose down -v) rồi dựng lại; dữ liệu cần giữ thì dùng pg_upgrade.

Sơ đồ: mạng compose, tên service là hostname
textReady
 máy host                       network mặc định của compose localhost:3000 ──publish──►  ┌───────────────────────────────────────┐                              │ app ──postgres:5432──► postgres  (vol)│                              │  └───redis:6379────► redis            │                              └───────────────────────────────────────┘ Trong container `app`, `localhost` là chính nó:   `localhost:5432` → connection refused. Cổng 5432 của postgres không `ports:` ra host thì chỉ app trong network thấy được.

Pitfall / case thực tế.

  • App connect localhost:5432 thay vì postgres:5432 → fail. Trong compose network, localhost là chính container đó, không phải host. Phải dùng tên service.
  • depends_on KHÔNG đợi DB sẵn sàng nhận query — chỉ đợi container start. App boot nhanh hơn Postgres init → crash "connection refused". Fix: dùng condition: service_healthy + healthcheck, hoặc retry logic trong app.
  • Quên volume cho Postgres → docker compose down là mất sạch data.

7. Environment tách biệt — 12-factor & secrets#

Định nghĩa. Cùng một codebase/image chạy được ở nhiều môi trường (dev / staging / prod), khác nhau CHỈ ở config truyền qua environment variables. Đây là factor III của phương pháp 12-Factor App.

Tại sao quan trọng. Không được hardcode DB url, API key, port theo môi trường vào code. Nếu hardcode, bạn phải build image khác nhau cho mỗi env → không đảm bảo "cái đã test ở staging chính là cái chạy ở prod". Tách config → 1 image, N môi trường, an toàn và reproducible.

Cơ chế.

  • Code đọc config qua process.env.DATABASE_URL, có validate lúc boot.
  • dev: file .env local (gitignored).
  • staging/prod: env vars inject bởi platform (Railway/Render dashboard, GitHub Secrets, Docker secrets, Vault...).
  • Secrets management: giá trị nhạy cảm (DB pass, JWT secret, API key) KHÔNG bao giờ commit; lưu ở secret store của platform, chỉ inject lúc runtime.

Ví dụ ngắn.

typescriptReady
// config.ts — validate & fail-fast lúc bootimport { z } from "zod";const env = z.object({  NODE_ENV: z.enum(["development", "test", "production"]),  // chế độ chạy của Node/thư viện  APP_ENV: z.enum(["local", "staging", "production"]).default("local"),  // môi trường triển khai  PORT: z.coerce.number().default(3000),  DATABASE_URL: z.url(),  JWT_SECRET: z.string().min(32),}).parse(process.env);   // thiếu/sai → crash ngay, không chạy nửa vờiexport default env;

NODE_ENV chỉ nhận development / test / production (Jest/Vitest tự đặt test, nhiều thư viện đổi hành vi theo giá trị này). Staging vẫn chạy NODE_ENV=production để đi đúng đường code của prod; thứ phân biệt staging với prod là APP_ENV. Đã chạy thử schema này trên zod 4.6: postgres://... qua z.url(), NODE_ENV=staging bị từ chối.

textReady
# .env.example (COMMIT cái này, không giá trị thật)DATABASE_URL=postgres://user:pass@localhost:5432/appJWT_SECRET=change-me-32-chars-minimum-xxxxxxxx

Pitfall / case thực tế.

  • Commit .env thật lên Git → secret lộ vĩnh viễn trong lịch sử (xóa file ở commit sau KHÔNG đủ, phải rotate key + rewrite history). Luôn .gitignore .env, chỉ commit .env.example.
  • Không validate env → app boot, chạy tới request đầu tiên mới chết vì DATABASE_URL undefined. Validate fail-fast lúc boot tốt hơn nhiều.
  • Bỏ qua staging, deploy thẳng lên prod → không có nơi test migration/config an toàn.

8. CI/CD — pipeline & GitHub Actions#

Định nghĩa.

  • CI (Continuous Integration): mỗi lần push, tự động chạy lint + test + build để phát hiện lỗi sớm.
  • CD (Continuous Delivery/Deployment): tự động (hoặc 1 nút bấm) đưa code đã pass lên môi trường chạy.
  • Pipeline: chuỗi bước tự động chạy theo trigger (push, PR, tag).

Tại sao quan trọng. Không ai muốn "test tay rồi FTP lên server". Pipeline biến quy trình ship thành tự động, nhất quán, có kiểm soát. PR không pass CI thì không merge → chất lượng bảo vệ tự động. Đây là xương sống của làm việc nhóm hiện đại.

Cơ chế. GitHub Actions: file YAML trong .github/workflows/. Định nghĩa on (trigger), jobs (chạy song song/tuần tự), mỗi job có steps. Runner (máy ảo GitHub cấp) checkout code rồi chạy từng step. Cache deps giữa các lần chạy để nhanh hơn.

Ví dụ ngắn.

textReady
name: CIon:  push: { branches: [main] }  pull_request:jobs:  test:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - uses: actions/setup-node@v7        with:          node-version: 24          cache: npm            # cache ~/.npm giữa các run      - run: npm ci      - run: npm run lint      - run: npm test      - run: npm run build  deploy:    needs: test                # chỉ deploy khi test pass    if: github.ref == 'refs/heads/main'    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - run: echo "deploy step (railway up / docker push / ssh...)"

Pitfall / case thực tế.

  • Không cache deps → mỗi run npm ci tải lại từ đầu, pipeline chậm gấp đôi. cache: npm hoặc actions/cache khắc phục.
  • Deploy chạy song song với test (không needs: test) → ship code chưa pass test lên prod.
  • Đổ secret vào log (echo $JWT_SECRET) → lộ trong CI log public. Dùng ${{ secrets.X }} và không in ra.
  • CI xanh nhưng dùng Node version khác local → bug chỉ hiện ở CI. Pin version giống nhau ở mọi nơi (kể cả Dockerfile).

Test với Postgres thật bằng service container#

Test tích hợp cần Postgres thật (mock DB che mất lỗi SQL). GitHub Actions dựng DB cạnh job bằng services; job chạy thẳng trên runner nên phải map port và nối qua localhost (GitHub Docs: PostgreSQL service containers, đọc 2026-10-05). Đây là phương án thay cho Testcontainers của GĐ13 (mục 14): chọn một trong hai, không chạy cả hai. Code tham chiếu, chưa chạy trên GitHub (cú pháp YAML đã parse thử bằng thư viện yaml; chưa lint bằng actionlint).

textReady
jobs:  test:    runs-on: ubuntu-latest    services:      postgres:        image: postgres:18-alpine        env:          POSTGRES_PASSWORD: postgres     # DB dùng một lần của runner, không phải secret thật          POSTGRES_DB: app_test           # kết thúc bằng _test để qua guard của GĐ13        options: >-          --health-cmd pg_isready          --health-interval 10s          --health-timeout 5s          --health-retries 5        ports:          - 5432:5432    env:      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test      MIGRATE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test   # nếu prisma.config.ts đọc biến này (GĐ14)    steps:      - uses: actions/checkout@v7      - uses: actions/setup-node@v7        with: { node-version: 24, cache: npm }      - run: npm ci      - run: npx prisma generate      # Prisma 7 không tự generate      - run: npx prisma migrate deploy      - run: npm test

options với --health-cmd làm GitHub chờ Postgres healthy rồi mới chạy step đầu, nên bước migrate không đụng DB chưa sẵn sàng. Với cách này không có globalSetup của Testcontainers cấp URL qua inject: test/db.ts phải đọc DATABASE_URL (và vẫn gọi guard _test của GĐ13 trên URL đó), nếu không test sẽ nối vào một DB khác với DB vừa migrate. Chạy migration trong CI trước test cũng kiểm luôn rằng chuỗi migration áp được lên DB trống.


9. Reverse proxy (Nginx)#

Định nghĩa. Reverse proxy là server đứng TRƯỚC app, nhận request từ client rồi forward tới app backend phía sau. Nginx là lựa chọn phổ biến.

Tại sao quan trọng. Không nên expose Node process trực tiếp ra Internet. Node giỏi xử lý logic nhưng không tối ưu cho việc terminate TLS, serve static, chống slow-client, load balance. Đặt Nginx phía trước để nó lo mấy việc đó, Node chỉ tập trung business logic.

Cơ chế.

  • proxy_pass: chuyển request tới upstream (app Node ở localhost:3000).
  • Load balancing: phân phối request cho nhiều instance Node (round-robin, least-conn).
  • TLS termination: Nginx giải mã HTTPS, nói HTTP với app phía sau → app không phải xử lý cert.
  • Thêm: serve static file, gzip, rate limit, buffer request chống slowloris.

Ví dụ ngắn.

textReady
upstream app {    server app1:3000;    server app2:3000;        # load balance 2 instance}server {    listen 443 ssl;    server_name api.example.com;    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;    location / {        proxy_pass http://app;        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;    }}
Sơ đồ: đường đi của một request qua proxy
textReady
 Client ──HTTPS(443)──► Nginx ──HTTP──► app1:3000                         │  giải mã TLS    app2:3000   (round-robin)                         │  thêm header: X-Forwarded-For,                         │               X-Forwarded-Proto: https App đọc X-Forwarded-Proto để biết client đã dùng HTTPS (Express: `trust proxy`); không có header đó app thấy HTTP → redirect http→https lặp vô hạn.

Pitfall / case thực tế.

  • Quên X-Forwarded-For / X-Forwarded-Proto → app thấy mọi request đến từ IP của Nginx (rate-limit sai) và tưởng là HTTP (redirect loop). Nhớ app.set('trust proxy', true) ở Express.
  • Expose Node trực tiếp port 80/443 và tự xử TLS trong app → mất tối ưu, mỗi lần renew cert phải restart app.
  • Nếu dùng platform PaaS (Railway/Render) thì reverse proxy + TLS đã được lo sẵn — không cần tự dựng Nginx. Chỉ cần khi tự quản VPS.

Location riêng cho SSE và stream LLM#

Cấu hình location / ở trên hỏng với stream: Nginx mặc định buffer response (proxy_buffering on), nên từng token LLM bị giữ lại và client nhận một cục khi xong; còn proxy_read_timeout mặc định 60s cắt kết nối nếu app im quá 60 giây giữa hai lần ghi (Nginx: ngx_http_proxy_module, đọc 2026-10-05). Tách một location cho đường stream (code tham chiếu, chưa chạy vì máy soạn không có Nginx):

textReady
location /api/chat/stream {    proxy_pass http://app;    proxy_http_version 1.1;          # mặc định chỉ là 1.1 từ Nginx 1.29.7 trở đi    proxy_set_header Connection "";  # giữ kết nối upstream, không gửi "close"    proxy_buffering off;             # đẩy từng chunk ngay    proxy_read_timeout 300s;         # lớn hơn khoảng lặng dài nhất giữa hai chunk    proxy_set_header Host $host;    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;    proxy_set_header X-Forwarded-Proto $scheme;}
  • Cách thay thế phía app: gửi header X-Accel-Buffering: no trong response (Express: res.setHeader("X-Accel-Buffering", "no")); Nginx tắt buffer cho response đó trừ khi có proxy_ignore_headers X-Accel-Buffering. Cách này đi cùng code, nên không phụ thuộc người sửa file Nginx.
  • Đừng chép cấu hình WebSocket sang SSE: WebSocket dùng map $http_upgrade $connection_upgrade rồi Upgrade/Connection; SSE là HTTP thường nên chỉ cần Connection "" như trên.
  • Heartbeat: dòng comment SSE (: ping) mỗi 15 đến 30 giây giữ cho cả Nginx lẫn load balancer phía trước không coi kết nối là rảnh.

10. HTTPS/TLS ở prod#

Định nghĩa. TLS mã hóa traffic giữa client và server (HTTPS = HTTP over TLS). Cert do một CA (Certificate Authority) ký; Let's Encrypt là CA miễn phí, tự động.

Tại sao quan trọng. Không HTTPS = password/token bay dạng plaintext, browser cảnh báo "Not Secure", nhiều API (cookie Secure, service worker, HTTP/2) yêu cầu HTTPS. Bắt buộc ở prod.

Cơ chế.

  • Let's Encrypt + Certbot: tự xin cert bằng ACME challenge (chứng minh bạn sở hữu domain), tự động renew qua cron. Hạn cert mặc định hiện vẫn là 90 ngày nhưng sắp ngắn lại (xem "Hạn cert đang ngắn dần" bên dưới), nên renew tự động và cảnh báo hết hạn không còn là tuỳ chọn.
  • Platform lo hộ: Railway, Render, Vercel, Cloudflare tự cấp và renew cert cho domain bạn gắn — bạn không đụng gì.

Ví dụ ngắn.

bashReady
# tự quản VPS: xin + tự renew cert cho Nginxsudo certbot --nginx -d api.example.com# certbot tự thêm dòng ssl_certificate vào Nginx và cài cron renew

Pitfall / case thực tế.

  • Cert hết hạn vì renew cron chết → prod sập với lỗi cert. Set alert theo dõi ngày hết hạn.
  • Terminate TLS ở Nginx rồi để app tưởng client dùng HTTP → tạo redirect http→https vô hạn. Đọc X-Forwarded-Proto thay vì tự đoán.
  • Với platform managed thì đừng tự cài Certbot — sẽ xung đột với TLS của platform.

Hạn cert đang ngắn dần#

Có hai lịch khác nhau, đừng trộn: một là trần do CA/Browser Forum đặt cho mọi CA, một là lịch riêng của Let's Encrypt.

NguồnMốcHạn tối đa / hạn cấp
CA/Browser Forum (ballot SC-081v3)từ 2026-03-15200 ngày
từ 2027-03-15100 ngày
từ 2029-03-1547 ngày
Let's Encrypt, profile classic (mặc định)hiện tại90 ngày
từ 2027-02-1064 ngày
từ 2028-02-1645 ngày
Let's Encrypt, profile tlsserver (opt-in)từ 2026-05-1345 ngày

Nguồn: Let's Encrypt: From 90 to 45 (đọc 2026-10-05); lịch CA/B Forum đọc qua bài tổng hợp SSL.com, chưa đọc văn bản ballot gốc (trang cabforum.org trả 404). Let's Encrypt còn có profile shortlived (6 ngày), nằm ngoài phạm vi bài này.

Hệ quả cho vận hành: đừng viết cứng chu kỳ renew kiểu "mỗi 60 ngày" (hỏng khi cert chỉ còn 45 ngày), để Certbot hoặc client ACME tự quyết, và bật cảnh báo khi cert sắp hết hạn mà chưa được renew. Bài của Let's Encrypt khuyên dùng ACME Renewal Information (ARI) và có monitoring cho việc này.


11. Deploy targets — PaaS vs VPS + Docker#

Định nghĩa.

  • PaaS (Railway / Render / Fly.io): platform managed, bạn đẩy code/Dockerfile, họ lo build, chạy, TLS, scaling, log.
  • VPS + Docker (DigitalOcean / Hetzner / EC2): bạn thuê máy trần, tự cài Docker, tự dựng compose/Nginx, tự lo mọi thứ.

Tại sao quan trọng. Chọn sai = tốn thời gian hoặc tốn tiền. Người mới nên bắt đầu bằng PaaS để ship nhanh; muốn hiểu sâu / kiểm soát chi phí thì chuyển VPS.

Cơ chế & trade-off.

Tiêu chíPaaS (Railway/Render)VPS + Docker
Tốc độ lên sóngVài phút, git push là chạyChậm, phải setup server
TLS / proxy / scalingTự độngTự dựng Nginx/Certbot
Kiểm soátÍt (bị giới hạn theo platform)Toàn quyền
Chi phíRẻ lúc nhỏ, đắt khi scaleRẻ hơn khi tải lớn, cố định
Học được gìÍt về opsHiểu sâu Linux/network/Docker
DebugLog qua dashboardSSH vào máy, full access

Ví dụ ngắn.

bashReady
# PaaS: deploy trong 1 lệnhrailway up# VPS: build + chạy bằng compose trên serverssh deploy@server 'cd /app && git pull && docker compose up -d --build'

Pitfall / case thực tế.

  • Chọn VPS quá sớm cho MVP → tốn hàng ngày setup thay vì viết feature. Bắt đầu bằng PaaS, migrate sau khi có traffic/hiểu nhu cầu.
  • PaaS free tier "ngủ" (cold start) sau thời gian idle → request đầu chậm vài giây. Biết để không hoảng khi demo.
  • VPS: quên firewall/ufw, để Postgres port 5432 mở ra Internet → bị scan và tấn công. Chỉ mở 80/443, DB chỉ nghe internal.

12. Zero-downtime, health check & log ở prod#

Định nghĩa.

  • Zero-downtime deploy: cập nhật version mới mà user không thấy gián đoạn.
  • Health check: hai endpoint để platform/proxy biết instance còn sống và sẵn sàng: /health/live (process còn phản hồi, không chạm DB) và /health/ready (sẵn sàng nhận traffic, có kiểm dependency).
  • Log ở prod: cách quan sát app đang chạy (stdout/stderr → log aggregator).

Tại sao quan trọng. Nếu deploy = tắt app cũ rồi bật app mới, sẽ có khoảng chết + nếu app mới lỗi thì sập luôn. Health check + rolling update để traffic chỉ chuyển sang instance mới KHI nó đã healthy. Log là mắt của bạn ở prod — không có log = debug mù.

Cơ chế.

  • Rolling / blue-green: khởi động instance mới → chờ nó pass health check → chuyển traffic → tắt instance cũ. Nếu mới fail health check thì giữ nguyên cũ (không sập).
  • Health endpoint: /health/live trả 200 miễn là process còn trả lời, không phụ thuộc bên ngoài (platform dùng nó để quyết định restart). /health/ready trả 200 khi dependency (DB) OK, 503 khi chưa sẵn sàng hoặc đang tắt → LB không route tới. Tách đôi vì nếu liveness chạm DB, DB chậm sẽ khiến platform restart cả đàn instance (→ GĐ18 mục 5).
  • Graceful shutdown: nhận SIGTERM → ngừng nhận request mới, xử nốt request đang chạy, đóng queue/DB pool sau khi HTTP đã drain, rồi exit. Mẫu code duy nhất của repo (đúng thứ tự, có timer ép thoát) ở GĐ09 mục 18; phần dưới chỉ nói những gì riêng của Docker.
  • Log: ghi ra stdout dạng JSON (structured), để platform/Docker gom. docker logs, dashboard PaaS, hoặc chuyển tới Datadog/Grafana Loki.

Ví dụ ngắn.

typescriptReady
// liveness: không chạm DBapp.get("/health/live", (_req, res) => res.status(200).json({ status: "ok" }));// readiness: phản ánh dependency thật và trạng thái đang tắtlet shuttingDown = false;   // handler SIGTERM đặt true (mẫu đầy đủ: GĐ09 mục 18)app.get("/health/ready", async (_req, res) => {  if (shuttingDown) return res.status(503).json({ status: "shutting_down" });  try { await db.raw("select 1"); res.status(200).json({ status: "ok" }); }  catch { res.status(503).json({ status: "degraded" }); }});

Phần riêng của Docker khi shutdown (code drain: GĐ09 mục 18):

  • PID 1 không có handler thì bỏ qua SIGTERM. Đã chạy node:24-alpine với server không đăng ký SIGTERM: docker stop -t 5 chờ đủ 5 s rồi SIGKILL, container thoát với exit 137. Vì vậy app luôn phải tự đăng ký handler.
  • tini (docker run --init, init: true trong compose) không làm shutdown graceful. Nó chỉ reap process zombie và chuyển tiếp signal. Cùng server không handler, chạy sau tini thì dừng ngay với exit 143 (tín hiệu mặc định giết process), tức là vẫn cắt request đang chạy. Kubernetes không có cờ --init: image cần tự cài tini (RUN apk add --no-cache tini, ENTRYPOINT ["/sbin/tini", "--"]) hoặc để app làm PID 1 với handler đúng.
  • STOPSIGNAL mặc định là SIGTERM; chỉ đổi khi framework bắt buộc dùng signal khác.
  • Grace period phải lớn hơn thời gian drain: docker stop -t <giây>, stop_grace_period trong compose, terminationGracePeriodSeconds trên Kubernetes (GĐ18 mục 6). Quá hạn thì runtime SIGKILL, dù app đang xử lý dở.
bashReady
docker logs -f --tail 100 <container>   # xem log realtimedocker compose logs -f app

Pitfall / case thực tế.

  • /health/ready chỉ trả 200 OK cứng, không kiểm DB → instance mất kết nối DB vẫn nhận traffic → user gặp lỗi 500 hàng loạt. Readiness nên phản ánh dependency thật (nhưng đừng để check quá nặng gây flapping). Ngược lại, đừng đặt kiểm DB vào /health/live.
  • Không xử SIGTERM → deploy làm rớt request đang chạy (mất data/transaction dở). Luôn graceful shutdown (GĐ09 mục 18).
  • Log ra file trong container → container xóa là mất log. Luôn log ra stdout, để hệ thống bên ngoài gom.
  • Log không có request-id/timestamp/context → có log nhưng vẫn không lần được nguyên nhân. Dùng structured logging (pino/winston JSON).

Áp dụng — Dự án 3#

Mục tiêu: Dự án 3 chạy local bằng docker-compose, deploy qua CI/CD.

Local (docker-compose):

  1. Viết Dockerfile multi-stage cho service Node (stage build → stage runtime, npm ci --omit=dev).

    Lời giải và cách kiểm tra

    Sơ đồ — layer cache và vì sao thứ tự COPY quan trọng

    textReady
     Lần build 1                 Lần build 2 (chỉ sửa src/app.ts) [1] FROM node:24-alpine     [1] cache [2] COPY package*.json      [2] cache      <- manifest không đổi [3] RUN npm ci              [3] cache      <- bỏ qua cài đặt (phần lâu nhất) [4] COPY . .                [4] BUILD LẠI  <- source đổi, cache vỡ từ đây [5] RUN npm run build       [5] BUILD LẠI Nếu [4] đứng trước [3]: sửa 1 dòng code làm [3] chạy lại mỗi lần.

    Code tham chiếu: Dockerfile

    textReady
    FROM node:24-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build                         # tsc → dist/FROM node:24-alpineWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=dev && npm cache clean --forceCOPY --from=build /app/dist ./dist# image node đã có sẵn user `node`USER nodeEXPOSE 3000HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \  CMD wget -qO- http://127.0.0.1:3000/health/live >/dev/null || exit 1CMD ["node", "dist/server.js"]

    HEALTHCHECK dùng /health/live (không chạm DB), đúng quy ước mục 12. Nếu app dùng Prisma 7, thêm bước npx prisma generate ở stage build và copy thư mục client đã sinh sang stage runtime theo output bạn cấu hình (xem GĐ05).

  2. Thêm .dockerignore (node_modules, .git, .env, dist).

    Lời giải và cách kiểm tra

    Code tham chiếu: .dockerignore

    textReady
    node_modules.git.env.env.*!.env.exampledistcoverage
  3. docker-compose.yml: services app + postgres + redis, network mặc định, volume pgdata, depends_on với condition: service_healthy, healthcheck cho Postgres.

    Lời giải và cách kiểm tra

    Code tham chiếu: docker-compose.yml (secret đọc từ .env, không ghi vào file này)

    textReady
    services:  postgres:    image: postgres:18-alpine            # cần pgvector thì đổi sang image có extension (tag: chưa xác minh)    environment:      POSTGRES_USER: app      POSTGRES_PASSWORD: ${DB_PASSWORD:?đặt DB_PASSWORD trong .env}      POSTGRES_DB: app    volumes:      - pgdata:/var/lib/postgresql        # image 18: VOLUME là /var/lib/postgresql (mục 6)    healthcheck:      test: ["CMD-SHELL", "pg_isready -U app -d app"]      interval: 5s      timeout: 3s      retries: 10  redis-cache:                            # cache: có thể mất, cho phép evict    image: redis:8-alpine    command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]  redis-queue:                            # BullMQ: không được evict (xem GĐ10)    image: redis:8-alpine    command: ["redis-server", "--maxmemory-policy", "noeviction", "--appendonly", "yes"]  migrate:                                # chạy một lần trước app    build: { context: ., target: build }  # stage build còn devDependencies (Prisma CLI)    env_file: .env    environment:      DATABASE_URL: postgres://app:${DB_PASSWORD}@postgres:5432/app    command: ["npx", "prisma", "migrate", "deploy"]    depends_on:      postgres: { condition: service_healthy }  app:    build: .    ports: ["3000:3000"]    env_file: .env    environment:      DATABASE_URL: postgres://app:${DB_PASSWORD}@postgres:5432/app      REDIS_CACHE_URL: redis://redis-cache:6379      REDIS_QUEUE_URL: redis://redis-queue:6379      APP_ENV: local                      # NODE_ENV=production đã đặt trong Dockerfile (mục 7)    stop_grace_period: 30s                # lớn hơn timer ép thoát 10 s của GĐ09 mục 18 (mục 12)    depends_on:      migrate: { condition: service_completed_successfully }      redis-cache: { condition: service_started }      redis-queue: { condition: service_started }volumes:  pgdata:
  4. Config qua env: config.ts validate bằng zod; .env (gitignored) + .env.example (commit).

    Lời giải và cách kiểm tra

    Đã chạy: config.ts dưới đây, trên zod 4.6.5 với Node 24 (chạy trực tiếp bằng type stripping, ngoài Docker): đủ biến thì in cấu hình và thoát 0; thiếu biến, NODE_ENV=staging hoặc JWT_SECRET ngắn thì in tên biến lỗi và thoát 1. Chưa chạy: .env.example bên dưới và việc nạp nó qua env_file của compose.

    Code tham chiếu: config.ts

    typescriptReady
    import { z } from "zod";const schema = z.object({  NODE_ENV: z.enum(["development", "test", "production"]),  APP_ENV: z.enum(["local", "staging", "production"]).default("local"),  PORT: z.coerce.number().int().positive().default(3000),  DATABASE_URL: z.url(),  REDIS_CACHE_URL: z.url(),  REDIS_QUEUE_URL: z.url(),  JWT_SECRET: z.string().min(32),});// thiếu hoặc sai biến → in tên biến lỗi rồi thoát, không chạy nửa vờiconst parsed = schema.safeParse(process.env);if (!parsed.success) {  console.error("Cấu hình env không hợp lệ:", z.flattenError(parsed.error).fieldErrors);  process.exit(1);}export const config = parsed.data;

    .env.example (commit; .env thật nằm trong .gitignore). Compose ghi đè DATABASE_URL và hai URL Redis bằng hostname service, nên các giá trị localhost dưới đây chỉ dùng khi chạy app ngoài Docker. File không đặt NODE_ENV: env_file ghi đè ENV NODE_ENV=production của Dockerfile, nên giá trị đó chỉ truyền ở dòng lệnh khi chạy ngoài Docker (NODE_ENV=development npm run dev).

    textReady
    APP_ENV=localPORT=3000DB_PASSWORD=change-meDATABASE_URL=postgres://app:change-me@localhost:5432/appREDIS_CACHE_URL=redis://localhost:6379REDIS_QUEUE_URL=redis://localhost:6380JWT_SECRET=change-me-32-chars-minimum-xxxxxxxx
  5. docker compose up --build → app connect DB qua hostname postgres, không phải localhost.

    Lời giải và cách kiểm tra

    Kết quả mong đợi (nghiệm thu cục bộ):

    bashReady
    docker compose up --build -ddocker compose ps -a                                # postgres: healthy; migrate: exited (0); app: runningcurl -s localhost:3000/health/live                  # {"status":"ok"}curl -s localhost:3000/health/ready                 # {"status":"ok"} khi DB kết nối đượcdocker compose stop postgrescurl -s -o /dev/null -w '%{http_code}\n' localhost:3000/health/ready    # 503 (live vẫn 200)docker compose start postgresdocker compose down && docker compose up -d         # KHÔNG dùng -v: dữ liệu còn nguyên nhờ volume pgdatadocker compose exec app sh -c 'echo $DATABASE_URL' | grep -c '@postgres:5432'   # 1: dùng tên service, không phải localhostdocker run --rm --entrypoint ls <image> -a /app | grep -c '^\.env'   # 0: không có .env trong imagedocker compose stop app                             # xong trong < 30s; log có dòng shutdown (mẫu GĐ09 mục 18)

CI/CD (GitHub Actions):

  1. Workflow on: [push, pull_request]: checkout → setup-node (cache npm) → npm ci → lint → test → build.

    Lời giải và cách kiểm tra

    Code tham chiếu: .github/workflows/ci.yml

    textReady
    name: CIon:  push: { branches: [main] }  pull_request:permissions:  contents: readjobs:  test:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - uses: actions/setup-node@v7        with:          node-version: 24          cache: npm      - run: npm ci      - run: npm run lint      - run: npm test      - run: npm run build  image:                                   # kiểm tra Dockerfile build được, chưa push    needs: test    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - uses: docker/build-push-action@v7        with:          context: .          push: false  deploy:    needs: [test, image]    if: github.event_name == 'push' && github.ref == 'refs/heads/main'    runs-on: ubuntu-latest    environment: production                # có thể đặt required reviewers trên environment    permissions:      id-token: write      contents: read    steps:      - uses: actions/checkout@v7      # Các bước ECR/ECS như workflow ở phần Bổ sung bên dưới (OIDC, không dùng access key dài hạn).      # Hoặc PaaS: dùng token trong secrets, ví dụ env: { TOKEN: "${{ secrets.DEPLOY_TOKEN }}" }      - run: echo "deploy"

    Khi job deploy dùng environment: production, claim sub của OIDC token chứa environment:production thay cho ref: (xem trust policy ở phần Bổ sung); sửa trust policy cho khớp trước khi chạy thật.

  2. Job deploy với needs: test và if: main → push image / railway up / ssh + docker compose up -d --build.

  3. Secrets qua ${{ secrets.* }}, không hardcode, không echo.

Prod:

  1. Deploy PaaS trước (Railway/Render) cho nhanh; TLS + proxy platform lo.
  2. Thêm /health/live (không chạm DB) và /health/ready (check DB) + graceful shutdown (SIGTERM, mẫu ở GĐ09 mục 18).
  3. Log structured ra stdout; xem qua dashboard.
Khung và mã dùng chung

Đây là đặc tả nên chỉ đưa khung, không viết cả dự án. Chưa chạy: toàn bộ khối dưới là code tham chiếu, chưa build bằng Docker, chưa chạy workflow, chưa deploy. actionlint không có sẵn trên máy này nên chưa lint YAML của các khối trong lời giải; các tên input/phiên bản đối chiếu theo bảng đã kiểm chứng ngày 2026-10-05 ở đầu stage. Mọi "kết quả mong đợi" là suy ra từ code và tài liệu.

Hướng làm. (1) Dockerfile multi-stage, chạy bằng user không phải root. (2) .dockerignore. (3) Compose có Postgres khoẻ trước khi app và bước migrate chạy. (4) config.ts validate env (mục 7). (5) CI, rồi mới đến deploy.

Phạm vi lời giải. CI/CD yêu cầu 2–3 và Prod yêu cầu 1–3 không có lời giải riêng: job deploy với needs và if nằm trong workflow ở CI/CD yêu cầu 1 và workflow ECS ở phần Bổ sung; secrets qua ${{ secrets.* }} đã nêu ở mục 8; Prod là chọn nền tảng và kiểm health/log, đã phủ ở mục 11, mục 12 và Done khi.

Lỗi hay gặp: localhost:5432 trong container; depends_on không có condition nên app boot trước DB; npx prisma migrate deploy trong image runtime không có Prisma CLI (đã --omit=dev), vì thế migrate dùng stage build; quên stop_grace_period nên bị SIGKILL giữa chừng (mặc định compose chỉ chờ 10 s); quên ENV NODE_ENV=production ở stage runtime nên framework chạy đường code của dev. CI: needs thiếu thì deploy chạy song song với test.

Sơ đồ — đường đi của một lần deploy

textReady
 git push main ─► test ─────────► image ──────────► deploy                  (lint, test,    (build thử)         │                   build)                             │ ECS: instance mới                    │ fail          │ fail            │ qua /health/ready                    ▼               ▼                 ▼ rồi mới nhận traffic              dừng, không deploy             instance cũ nhận SIGTERM,                                             drain, thoát

Done khi#

  • Giải thích được image vs container, container vs VM bằng lời của mình.

    Đáp án

    Image là template bất biến, nhiều layer, chỉ đọc; container là một instance đang chạy với lớp ghi mỏng ở trên (như class và object). VM ảo hoá cả phần cứng và chạy kernel riêng; container dùng chung kernel host, chỉ cô lập process, filesystem, network nên nhẹ và start nhanh. Sai thường gặp: sửa file trong container rồi docker rm là mất. Xem mục 2.

  • Viết được Dockerfile multi-stage cho Node đúng thứ tự cache (COPY package.json trước, npm ci, rồi COPY source).

    Đáp án

    Stage build: COPY package*.json → npm ci → COPY . . → npm run build. Stage runtime: npm ci --omit=dev + COPY --from=build /app/dist. Tự kiểm: sửa một dòng .ts rồi build lại, layer npm ci phải hiện CACHED. Sai thường gặp: COPY . . đứng trước npm ci; copy cả node_modules từ builder. Xem mục 3 và mục 4.

  • Có .dockerignore loại node_modules/.env/.git.

    Đáp án

    Có node_modules, .git, .env, .env.*, dist, coverage. Tự kiểm: docker run --rm --entrypoint ls <image> -a /app không liệt kê .env (docker history chỉ hiện lệnh như COPY . ., không chứng minh được gì về tên file); secret nằm trong layer thì xoá ở layer sau cũng không mất. Xem mục 5.

  • docker compose up dựng được app + Postgres + Redis, app connect DB qua tên service, data bền qua volume.

    Đáp án

    App dùng hostname postgres (không localhost), depends_on với condition: service_healthy, volume pgdata. Tự kiểm: docker compose ps thấy healthy; docker compose down rồi up (không -v) dữ liệu còn. depends_on thường chỉ đảm bảo start, không đảm bảo ready. Xem mục 6.

  • Config toàn bộ qua env (validate fail-fast), .env gitignored, chỉ commit .env.example.

    Đáp án

    config.ts parse process.env bằng zod lúc boot (NODE_ENV ∈ development/test/production, APP_ENV cho môi trường triển khai); .env trong .gitignore, chỉ commit .env.example. Tự kiểm: xoá DATABASE_URL rồi chạy, process phải thoát ngay với lỗi rõ. Secret đã commit nhầm: rotate key, không chỉ xoá file. Xem mục 7.

  • Có workflow GitHub Actions: lint → test → build, cache deps, deploy chỉ khi test pass và đúng branch.

    Đáp án

    actions/setup-node@v7 với cache: npm; job deploy có needs: test và if: github.ref == 'refs/heads/main'. Tự kiểm: mở PR thì job deploy bị skip; làm test đỏ thì deploy không chạy; lần chạy thứ hai npm ci nhanh hơn nhờ cache. Xem mục 8.

  • Hiểu vì sao không expose Node trực tiếp; biết reverse proxy + TLS termination làm gì (dù dùng platform lo hộ).

    Đáp án

    Proxy đứng trước Node: kết thúc TLS, load balance, đệm client chậm, thêm X-Forwarded-For/X-Forwarded-Proto; Node chỉ lo logic. Express cần trust proxy để thấy IP và scheme thật. Dùng PaaS thì nhà cung cấp lo hộ, nhưng bạn vẫn phải biết chuyện gì xảy ra. Xem mục 9 và mục 10.

  • Chọn được deploy target phù hợp và nêu được trade-off PaaS vs VPS.

    Đáp án

    PaaS: ra mắt nhanh, ít kiểm soát, rẻ khi nhỏ; VPS + Docker: toàn quyền, rẻ hơn khi tải lớn, tự lo TLS/firewall/vá. MVP chọn PaaS; có yêu cầu kiểm soát hoặc chi phí thì mới sang VPS/ECS. Nêu được ít nhất một rủi ro mỗi bên (cold start với free tier; cổng DB mở ra Internet trên VPS). Xem mục 11.

  • Có /health/live (không chạm DB) và /health/ready (phản ánh dependency), có graceful shutdown, biết xem log prod.

    Đáp án

    /health/live không chạm DB (platform dùng để restart), /health/ready kiểm DB và trả 503 khi shuttingDown. Tự kiểm: dừng Postgres thì ready thành 503 còn live vẫn 200; gửi SIGTERM khi đang có request chậm thì request đó vẫn hoàn tất, process thoát sau khi drain. Log JSON ra stdout, xem bằng docker compose logs -f app. Xem mục 12 và GĐ09 mục 18.

  • Deploy thật Dự án 3 lên một môi trường và truy cập được qua HTTPS.

    Đáp án

    Tự kiểm sau khi bạn tự deploy: curl -sI https://<domain>/health/ready trả 200, trình duyệt không cảnh báo chứng chỉ, http:// chuyển sang https:// không lặp vô hạn (cần X-Forwarded-Proto), env thiếu thì deploy fail ở bước boot. Mục này đòi hỏi tài khoản và domain của chính bạn; tài liệu này không deploy hộ. Xem mục 11 và mục 12.

  • Giải thích được vai trò của EC2, S3, IAM, RDS, Lambda, CloudWatch và VPC; dựng được sơ đồ mạng đơn giản cho API + database + upload.

    Đáp án

    EC2: máy ảo; S3: lưu object; IAM: danh tính và quyền; RDS: database managed; Lambda: hàm theo sự kiện; CloudWatch: log, metric, alarm; VPC: mạng riêng với subnet, route, security group. Sơ đồ tối thiểu: ALB ở public subnet → ECS/EC2 ở private subnet → RDS ở subnet database không có đường ra Internet; upload đi thẳng lên S3 bằng presigned URL, ứng dụng chỉ giữ khoá. Bài học đầy đủ và lab ở GĐ16.


Câu hỏi mở (tự trả lời sau)#

  • Khi nào cần Kubernetes thay vì docker-compose? (gợi ý: khi cần scale nhiều node, self-healing, service mesh — đa số dự án nhỏ CHƯA cần).

    Hướng trả lời hiện tại

    Kubernetes: khi có ít nhất hai trong ba điều kiện ở phần "Bước tiếp theo" (nhiều service triển khai độc lập, nhiều môi trường/vùng, công ty đã dùng K8s); một backend và một worker thì compose hoặc ECS Fargate đủ.

    (Chưa chốt, không phải khẳng định chắc chắn.)

  • Chiến lược migration DB an toàn trong CD pipeline (chạy migration trước hay sau khi deploy app version mới)?

    Hướng trả lời hiện tại

    Migration trong CD: chạy migration trước khi bật version app mới (bước một lần, như service migrate ở lời giải Dự án 3), và viết migration tương thích với cả version cũ lẫn mới để rolling deploy không lỗi (xem GĐ14 mục 10). Thao tác huỷ (DROP) để lần deploy sau.

    (Chưa chốt, không phải khẳng định chắc chắn.)

  • Observability sâu hơn: metrics (Prometheus) + tracing (OpenTelemetry) khác gì với logging thuần?

    Hướng trả lời hiện tại

    Observability: log trả lời "chuyện gì đã xảy ra ở một request", metrics trả lời "hệ thống đang khoẻ đến đâu theo thời gian" (tỉ lệ lỗi, latency, độ bão hoà), tracing nối các bước của một request qua nhiều service. Bắt đầu bằng log có cấu trúc cùng request-id, thêm metrics và tracing khi có nhiều hơn một service hoặc cần đo SLO.

    (Chưa chốt, không phải khẳng định chắc chắn.)


☁️ Bổ sung — Deploy Vercel & AWS (theo mục tiêu AI SaaS)#

Phần chính ở trên dùng Railway/Render/VPS. Đây là 2 nền tảng phổ biến nhất cho AI SaaS: Vercel (frontend + serverless) và AWS (backend/scale).

Vercel — cho Frontend (Next.js) & serverless nhẹ#

  • Định nghĩa: platform tối ưu cho Next.js/frontend; deploy = git push, auto build + CDN global + preview URL mỗi PR.
  • Tại sao dùng: FE của AI SaaS (Next.js) đặt ở Vercel là chuẩn mực; DX cực tốt, HTTPS/CDN tự lo.
  • Cơ chế: mỗi push → build → deploy immutable; env vars set trong dashboard; serverless/edge functions cho API route nhẹ.
  • Giới hạn quan trọng (case AI SaaS): serverless function có timeout (với Fluid compute, mặc định 300 s ở mọi plan, tối đa 800 s ở Pro/Enterprise; xem trang limits của Vercel trước khi chọn) và không giữ state giữa các request → một lần gọi LLM streaming vài chục giây vẫn chạy được, nhưng KHÔNG hợp cho tác vụ vượt trần thời gian, background job, WebSocket bền, kết nối DB pool lâu.
    • → Kiến trúc thực tế: FE + BFF nhẹ trên Vercel, còn backend nặng (stream rất dài, queue, RAG ingest) đặt trên AWS/Railway — Vercel gọi sang.
  • Pitfall: nhét cả backend nặng vào Vercel functions → timeout, cold start, cạn DB connection (dùng pooler như Neon/Supabase pooling).

AWS — cho Backend production & scale#

Lộ trình bảy dịch vụ AWS — EC2, S3, IAM, RDS, Lambda, CloudWatch và VPC — có bài học riêng tại GĐ16, gồm sơ đồ mạng, cấu hình quyền và ví dụ Node.js. GĐ15 tập trung vào lựa chọn nơi chạy app và quy trình deploy.

Các cách chạy Node backend, từ dễ → linh hoạt:

  • Elastic Beanstalk / App Runner: managed, gần giống Railway; nhanh, ít cấu hình.
  • ECS + Fargate (khuyên cho SaaS): chạy Docker container (đúng Dockerfile bạn học ở trên) không cần quản server; auto-scale theo CPU/req.
  • EC2: VPS thuần, tự cài Docker/Nginx — hiểu sâu nhất nhưng tự quản nhiều.
  • Lambda: serverless function; hợp task ngắn/event. Invocation có timeout tối đa 15 phút; response streaming có hỗ trợ nhưng phụ thuộc cách gọi, tích hợp và Region, nên phải kiểm tra các giới hạn của đường đi cụ thể.

Dịch vụ AWS đi kèm 1 AI SaaS thật:

  • RDS (Postgres, bật pgvector) hoặc dùng Neon/Supabase ngoài AWS cho nhanh.
  • ElastiCache (Redis) cho cache/queue.
  • S3 lưu file user upload (tài liệu để RAG).
  • CloudWatch logs/metrics/alarm; Secrets Manager giữ API key LLM/Stripe.
  • ALB (load balancer) + HTTPS (ACM cert) trước ECS.

Luồng deploy điển hình (CI/CD)#

  1. GitHub Actions: lint → test → docker build → push image lên ECR.
  2. Đăng ký task definition revision mới trỏ tới image SHA vừa push, rồi update ECS service → rolling deploy (zero-downtime nhờ health check).
  3. FE riêng: push → Vercel auto deploy.

Dưới đây là job deploy ở dạng rút gọn; job lint/test ở mục 8 chạy trước, nối bằng needs:.

textReady
# .github/workflows/deploy.ymlname: Deploy APIon:  push: { branches: [main] }permissions:  id-token: write      # cho phép job xin OIDC token để assume role (không cần access key dài hạn)  contents: readconcurrency:  group: deploy-prod  cancel-in-progress: falseenv:  AWS_REGION: ap-southeast-1  ECR_REPOSITORY: app  ECS_CLUSTER: prod  ECS_SERVICE: app  CONTAINER_NAME: app       # trùng "name" của container trong task definitionjobs:  deploy:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - uses: aws-actions/configure-aws-credentials@v6        with:          role-to-assume: arn:aws:iam::123456789012:role/github-deploy          aws-region: ${{ env.AWS_REGION }}      - id: ecr        uses: aws-actions/amazon-ecr-login@v2      - id: image        run: echo "uri=${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"      - uses: docker/build-push-action@v7        with:          context: .          push: true          tags: ${{ steps.image.outputs.uri }}      # lấy task definition đang chạy, thay image bằng bản vừa push, rồi đăng ký revision mới      - run: |          aws ecs describe-task-definition --task-definition "$ECS_SERVICE" \            --query taskDefinition > task-definition.json      - id: render        uses: aws-actions/amazon-ecs-render-task-definition@v1        with:          task-definition: task-definition.json          container-name: ${{ env.CONTAINER_NAME }}          image: ${{ steps.image.outputs.uri }}      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2        with:          task-definition: ${{ steps.render.outputs.task-definition }}          service: ${{ env.ECS_SERVICE }}          cluster: ${{ env.ECS_CLUSTER }}          wait-for-service-stability: true   # chờ rolling deploy xong, fail job nếu không ổn định

Vì sao đủ bước này mới chạy được:

  • Không có configure-aws-credentials thì runner chưa có quyền AWS nào, amazon-ecr-login và aws ecs ... đều fail. Dùng OIDC (permissions: id-token: write) để job tự xin credentials tạm thời, không lưu access key dài hạn trong GitHub Secrets.
  • --force-new-deployment một mình chỉ chạy lại cùng task definition revision (image tag trong đó không đổi), nên không đưa image $GITHUB_SHA lên. Bước describe-task-definition → render → deploy đăng ký revision mới có image mới; wait-for-service-stability giữ job đến khi rolling deploy ổn định.
  • Role github-deploy cần quyền đẩy image lên ECR, ecs:DescribeTaskDefinition, ecs:RegisterTaskDefinition, ecs:UpdateService, ecs:DescribeServices và iam:PassRole cho task role / execution role khai báo trong task definition.
  • Trust policy của role phải khớp claim sub của token. Dạng cũ: repo:OWNER/REPO:ref:refs/heads/main. Repo tạo từ 2026-07-15 (hoặc đã opt-in, hoặc đổi tên sau mốc đó) phát claim bất biến kèm ID: repo:OWNER@ORG_ID/REPO@REPO_ID:ref:refs/heads/main. Job chạy trong GitHub environment thì sub chứa environment:<tên> thay cho ref:. Trust policy sai dạng sẽ báo Not authorized to perform sts:AssumeRoleWithWebIdentity (README của action).
  • Mức đã kiểm: tên input đối chiếu với action.yml của từng action ở các major trên; workflow này chưa lint lại bằng actionlint trong lần soát gần nhất (máy không có sẵn) và chưa chạy deploy thật lên AWS.

Mẫu trust policy cho role github-deploy (code tham chiếu, chưa chạy; dạng điều kiện theo README của configure-aws-credentials). Role này cần OIDC provider token.actions.githubusercontent.com đã tạo trong IAM, và Principal là provider đó (Federated), không phải một tài khoản:

jsonReady
{  "Version": "2012-10-17",  "Statement": [{    "Effect": "Allow",    "Principal": {      "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"    },    "Action": "sts:AssumeRoleWithWebIdentity",    "Condition": {      "StringEquals": {        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",        "token.actions.githubusercontent.com:sub": "repo:OWNER/REPO:ref:refs/heads/main"      }    }  }]}

aud phải là sts.amazonaws.com (giá trị mặc định của action). StringEquals khớp đúng từng ký tự, nên an toàn; chỉ chuyển sang StringLike với ký tự đại diện (ví dụ repo:OWNER/REPO:*) khi bạn chủ ý cho mọi nhánh, pull request và environment của repo đó assume role, vì như vậy bất kỳ ai đẩy được một nhánh vào repo đều deploy được. Với repo dùng claim bất biến hoặc job có environment, đổi giá trị sub sang dạng tương ứng: repo:OWNER@ORG_ID/REPO@REPO_ID:ref:refs/heads/main hoặc repo:OWNER/REPO:environment:production.

Rollback bằng revision task definition trước#

Mỗi lần deploy ở trên đăng ký một revision mới và giữ revision cũ, nên rollback là trỏ service về revision trước, không cần build lại. Code tham chiếu, chưa chạy (không có tài khoản AWS):

bashReady
# liệt kê revision mới nhất trước; --family-prefix khớp theo tiền tố# nên "app" cũng khớp "app-worker": lọc đúng tên family nếu có nhiều serviceaws ecs list-task-definitions --family-prefix app --sort DESC \  --max-items 5 --query 'taskDefinitionArns' --output text# trỏ service về revision đã biết là tốt (đổi task definition tự kích hoạt deploy)aws ecs update-service --cluster prod --service app --task-definition app:41aws ecs wait services-stable --cluster prod --services app

--task-definition nhận family:revision và việc đổi nó tự bắt đầu một deployment, không cần --force-new-deployment (update-service, list-task-definitions, đọc 2026-10-05). Rollback chỉ đưa code về; schema DB đã migrate không tự lùi (xem phần migration bên dưới).

Muốn ECS tự lùi khi deploy hỏng, bật deployment circuit breaker trên service dùng rolling update:

bashReady
aws ecs update-service --cluster prod --service app \  --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}"

Khi deployment fail, ECS lùi về deployment gần nhất ở trạng thái COMPLETED; nếu chưa có deployment nào COMPLETED thì nó không lùi được và deployment kẹt lại (ECS: deployment circuit breaker, đọc 2026-10-05). Circuit breaker chỉ phát hiện tác vụ không chạy hoặc không qua health check, nên một bug logic vẫn lên prod; health check của mục 12 mới là thứ làm nó có ích.

Chạy migration trước khi đổi service#

Quy tắc: migration là một bước riêng, chạy một lần, và job deploy needs nó. Không để mỗi instance app tự migrate lúc khởi động, vì khi scale ra nhiều instance cùng lúc thì nhiều tiến trình cùng áp một migration.

bashReady
# chạy một task một lần bằng image có Prisma CLI (target build, như service migrate ở compose)TASK_ARN=$(aws ecs run-task --cluster prod --task-definition app-migrate:7 \  --launch-type FARGATE --count 1 \  --network-configuration 'awsvpcConfiguration={subnets=[subnet-aaa],securityGroups=[sg-bbb],assignPublicIp=DISABLED}' \  --overrides '{"containerOverrides":[{"name":"app","command":["npx","prisma","migrate","deploy"]}]}' \  --query 'tasks[0].taskArn' --output text)aws ecs wait tasks-stopped --cluster prod --tasks "$TASK_ARN"CODE=$(aws ecs describe-tasks --cluster prod --tasks "$TASK_ARN" \  --query 'tasks[0].containers[0].exitCode' --output text)test "$CODE" = "0"          # khác 0: dừng pipeline, KHÔNG update-service
  • wait tasks-stopped hỏi mỗi 6 giây, tối đa 100 lần (khoảng 10 phút), hết lượt thì thoát mã 255; nó chỉ chờ task dừng, không cho biết task thành công hay thất bại, nên phải đọc exitCode ở describe-tasks (wait tasks-stopped, run-task, đọc 2026-10-05).
  • Nếu run-task thất bại ngay (thiếu quyền, hết capacity), tasks rỗng và TASK_ARN là None: kiểm failures trong output trước khi wait.
  • Khoá: để hai pipeline không migrate cùng lúc, dùng concurrency của workflow (nhóm deploy-prod ở trên, cancel-in-progress: false). Việc Prisma 7 tự chờ nhau qua khoá của Postgres khi chạy migrate deploy đồng thời: chưa xác minh trong tài liệu 7.x, nên đừng dựa vào đó.
  • Tương thích hai chiều: migration chạy trước khi version mới lên, version cũ vẫn đang phục vụ, nên mỗi migration phải chạy được với cả code cũ lẫn code mới (thêm cột nullable trước, bỏ cột ở lần deploy sau); mẫu ở GĐ14 mục 10.

Workflow thật của repo này làm ví dụ#

Repo này tự deploy bằng .github/workflows/deploy.yml, đáng đọc vì nó dùng các ý ở mục 8 trên một việc có thật (build site VitePress rồi đẩy lên Cloudflare Pages):

  • Hai job build và deploy, nối bằng needs: build; deploy chỉ chạy với push lên main hoặc khi kích hoạt tay (workflow_dispatch) trên main (if chặn pull request), nên PR chỉ build.
  • concurrency nhóm theo github.ref với cancel-in-progress: true: commit mới huỷ lần chạy cũ của cùng nhánh. Hợp với site tĩnh; deploy API ở trên đặt cancel-in-progress: false vì huỷ giữa chừng một migration là nguy hiểm.
  • Artifact site truyền kết quả build sang job deploy, nên job deploy không build lại.
  • Một step kiểm secret Cloudflare và chỉ cảnh báo (không fail) khi thiếu, vì secrets không dùng được trong if ở cấp job; fork không có secret vẫn build được.
  • Phiên bản CLI wrangler được ghim để khớp với bản dùng khi phát triển local.

Chọn cái nào?#

  • Mới học / MVP: FE→Vercel, backend→Railway (hoặc App Runner). Nhanh nhất.
  • Production/scale: FE→Vercel, backend→ECS Fargate + RDS(pgvector) + ElastiCache + S3.

Pitfall thực tế#

  • Serverless có giới hạn runtime và kết nối; Lambda hỗ trợ response streaming ở một số đường invoke. Kiểm tra giới hạn cụ thể trước khi chọn cho LLM/API dài; worker dài hạn hoặc queue thường dễ vận hành hơn trên ECS/VPS.
  • DB connection cạn khi scale nhiều instance → dùng connection pooler.
  • Quên set health check → deploy làm rớt request.

Done bổ sung khi#

  • Dự án 3 (Mini SaaS API; pgvector chỉ cần khi làm thêm DocuChat ở GĐ22 đến GĐ25): FE chạy Vercel, backend Docker chạy trên AWS ECS Fargate (hoặc Railway), Postgres + Redis managed, deploy qua GitHub Actions.

🔜 Bước tiếp theo — AWS GĐ16, Cloudflare GĐ17, rồi Kubernetes#

GĐ15 dừng ở Docker + PaaS/VPS/ECS. Với một backend + một worker, docker compose trên VPS hoặc ECS Fargate làm được đúng việc mà Kubernetes làm, với khoảng 5% độ phức tạp. Tiếp theo là GĐ16 AWS: hiểu quyền, mạng, database, object storage và cách vận hành một lab nhỏ. Sau đó, GĐ17 Cloudflare áp dụng backend trên Workers, R2, D1 và Queues để so sánh với runtime AWS.

Vì sao vẫn phải học K8s — và học ở GĐ18:

  • Nó có mặt trong mô tả công việc và phỏng vấn ở hầu hết công ty tầm trung trở lên.
  • Các khái niệm của nó (declarative, reconciliation loop, liveness vs readiness, resource request/limit, graceful shutdown) là cách tư duy đúng về vận hành, có giá trị kể cả khi bạn chạy trên PaaS.

Học theo thứ tự. Trước K8s, cần Dockerfile multi-stage sạch, CI/CD tự động, ít nhất một lần deploy thủ công thành công ở GĐ15, rồi hoàn thành lab AWS ở GĐ16 và Cloudflare ở GĐ17. K8s không xoá được bài toán nào bạn sắp gặp — app vẫn phải stateless, vẫn cần graceful shutdown, health check và connection pooling (→ GĐ21).

Khi nào K8s thật sự đáng dùng cho dự án của bạn (không phải để học): khi có ít nhất hai trong ba điều sau xảy ra thật — (a) nhiều hơn 4–5 service triển khai độc lập; (b) nhiều môi trường/nhiều vùng cần cấu hình giống hệt nhau; (c) công ty đã dùng K8s và bạn cần làm việc trong đó.

Infrastructure as Code (Terraform/Pulumi) vẫn nằm ngoài lộ trình này. Học nó ngay khi bạn không còn nhớ được tài nguyên đã tạo bằng Console hoặc cần lặp lại môi trường. Thứ tự gợi ý: lab AWS ở GĐ16 → Terraform mô tả VPC/EC2/RDS hiện có → GĐ18 trên cluster local (kind/k3d) → Helm/Kustomize → GitOps (ArgoCD). Đừng bắt đầu bằng cách dựng cluster production.