Đi đến nội dung
·33 phút đọc

GitHub Actions CI/CD: Triển Khai Zero-Downtime Thực Sự Hoạt Động

Cấu hình GitHub Actions hoàn chỉnh: parallel test jobs, Docker build caching, SSH deployment tới VPS, zero-downtime với PM2 reload, quản lý secrets, và các workflow pattern tôi đã tinh chỉnh qua hai năm.

Chia sẻ:X / TwitterLinkedIn

Mọi dự án tôi từng làm đều đến cùng một điểm bước ngoặt: quy trình deploy trở nên quá đau đớn để làm thủ công. Bạn quên chạy tests. Bạn build local nhưng quên bump version. Bạn SSH vào production và phát hiện người deploy lần trước để lại file .env cũ.

GitHub Actions đã giải quyết điều này cho tôi hai năm trước. Không hoàn hảo ngay ngày đầu tiên — workflow đầu tiên tôi viết là một cơn ác mộng YAML 200 dòng, timeout một nửa thời gian và không cache gì cả. Nhưng qua từng lần cải tiến, tôi đã đạt được một thứ deploy trang web này một cách đáng tin cậy, với zero downtime, trong vòng chưa đến bốn phút.

Đây là workflow đó, được giải thích từng phần. Không phải phiên bản docs. Mà là phiên bản sống sót qua thực tế production.

Hiểu Các Thành Phần Cơ Bản#

Trước khi đi vào pipeline đầy đủ, bạn cần có mô hình tư duy rõ ràng về cách GitHub Actions hoạt động. Nếu bạn đã dùng Jenkins hay CircleCI, hãy quên hầu hết những gì bạn biết. Các khái niệm ánh xạ lỏng lẻo, nhưng mô hình thực thi khác biệt đủ để gây nhầm lẫn.

Triggers: Khi Nào Workflow Chạy#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Mỗi thứ Hai lúc 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Bốn trigger, mỗi cái phục vụ mục đích khác nhau:

  • push vào main là trigger deploy production. Code được merge? Ship thôi.
  • pull_request chạy các CI check trên mỗi PR. Đây là nơi lint, type check và tests nằm.
  • schedule là cron cho repo của bạn. Tôi dùng nó cho quét audit dependency hàng tuần và dọn dẹp cache cũ.
  • workflow_dispatch cho bạn nút "Deploy" thủ công trong GitHub UI với các tham số input. Vô giá khi bạn cần deploy staging mà không thay đổi code — có thể bạn đã cập nhật biến môi trường hoặc cần repull base Docker image.

Một điều thường khiến người ta bị sốc: pull_request chạy trên merge commit, không phải HEAD của branch PR. Điều này có nghĩa CI của bạn đang test code sẽ trông như thế nào sau khi merge. Thực ra đó là điều bạn muốn, nhưng nó khiến mọi người ngạc nhiên khi một branch xanh chuyển thành đỏ sau khi rebase.

Jobs, Steps và Runners#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Jobs chạy song song theo mặc định. Mỗi job nhận một VM mới (gọi là "runner"). ubuntu-latest cho bạn một máy khá mạnh — 4 vCPU, 16 GB RAM tính đến 2026. Miễn phí cho repo public, 2000 phút/tháng cho private.

Steps chạy tuần tự trong một job. Mỗi step uses: kéo một action có thể tái sử dụng từ marketplace. Mỗi step run: thực thi một lệnh shell.

Flag --frozen-lockfile rất quan trọng. Không có nó, pnpm install có thể cập nhật lockfile trong CI, nghĩa là bạn không test cùng dependencies mà developer đã commit. Tôi đã thấy điều này gây ra các test failure ảo biến mất ở local vì lockfile trên máy developer đã đúng sẵn.

Biến Môi Trường vs Secrets#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

Biến môi trường đặt với env: ở mức workflow là văn bản thuần, hiển thị trong logs. Dùng chúng cho config không nhạy cảm: NODE_ENV, cờ telemetry, feature toggles.

Secrets (${{ secrets.X }}) được mã hóa khi lưu trữ, che trong logs, và chỉ có sẵn cho workflows trong cùng repo. Chúng được đặt trong Settings > Secrets and variables > Actions.

Dòng environment: production rất quan trọng. GitHub Environments cho phép bạn giới hạn secrets theo từng mục tiêu deployment cụ thể. SSH key staging và SSH key production có thể đều được đặt tên SSH_PRIVATE_KEY nhưng giữ giá trị khác nhau tùy thuộc vào environment mà job nhắm đến. Điều này cũng mở khóa required reviewers — bạn có thể chặn production deploys phải có phê duyệt thủ công.

Pipeline CI Đầy Đủ#

Đây là cách tôi cấu trúc nửa CI của pipeline. Mục tiêu: bắt mọi loại lỗi trong thời gian nhanh nhất có thể.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Tại Sao Cấu Trúc Này#

Lint, typecheck và test chạy song song. Chúng không có phụ thuộc vào nhau. Type error không chặn lint chạy, và test thất bại không cần chờ type checker. Trong một lần chạy điển hình, cả ba hoàn thành trong 30-60 giây khi chạy đồng thời.

Build chờ cả ba. Dòng needs: [lint, typecheck, test] nghĩa là build job chỉ bắt đầu nếu lint, typecheck VÀ test đều pass. Không có lý do gì build một dự án có lỗi lint hay type.

concurrency với cancel-in-progress: true tiết kiệm thời gian cực lớn. Nếu bạn push hai commit liên tiếp nhanh, lần chạy CI đầu tiên bị hủy. Không có điều này, bạn sẽ có các lần chạy cũ tiêu tốn ngân sách phút và làm lộn xộn UI checks.

Upload coverage với if: always() nghĩa là bạn nhận được báo cáo coverage ngay cả khi tests fail. Điều này hữu ích cho debugging — bạn có thể xem tests nào failed và chúng cover những gì.

Fail-Fast vs. Cho Tất Cả Chạy#

Theo mặc định, nếu một job trong matrix fail, GitHub hủy các job còn lại. Với CI, tôi thực sự muốn hành vi này — nếu lint fail, tôi không quan tâm kết quả test. Sửa lint trước.

Nhưng với test matrices (ví dụ, test trên Node 20 và Node 22), bạn có thể muốn xem tất cả failures cùng lúc:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false cho phép cả hai nhánh matrix hoàn thành. Nếu Node 22 fail nhưng Node 20 pass, bạn thấy thông tin đó ngay lập tức thay vì phải chạy lại.

Caching Để Tăng Tốc#

Cải thiện đáng kể nhất bạn có thể làm cho tốc độ CI là caching. Một pnpm install nguội trên dự án trung bình mất 30-45 giây. Với cache nóng, chỉ mất 3-5 giây. Nhân lên trên bốn job song song và bạn tiết kiệm được hai phút mỗi lần chạy.

Cache Pnpm Store#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Dòng đơn này cache pnpm store (~/.local/share/pnpm/store). Khi trúng cache, pnpm install --frozen-lockfile chỉ hard-link từ store thay vì download. Riêng điều này đã cắt giảm thời gian install 80% trên các lần chạy lặp lại.

Nếu bạn cần kiểm soát nhiều hơn — ví dụ, bạn muốn cache dựa trên OS nữa — dùng actions/cache trực tiếp:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

Fallback restore-keys rất quan trọng. Nếu pnpm-lock.yaml thay đổi (dependency mới), key chính xác sẽ không khớp, nhưng prefix match vẫn khôi phục hầu hết packages đã cache. Chỉ phần khác biệt mới được download.

Cache Build Next.js#

Next.js có cache build riêng trong .next/cache. Cache giữa các lần chạy nghĩa là incremental builds — chỉ các trang và component thay đổi mới được biên dịch lại.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Chiến lược key ba cấp này nghĩa là:

  1. Khớp chính xác: cùng dependencies VÀ cùng source files. Cache hit hoàn toàn, build gần như tức thì.
  2. Khớp một phần (dependencies): dependencies giống nhưng source thay đổi. Build chỉ biên dịch lại file đã thay đổi.
  3. Khớp một phần (chỉ OS): dependencies thay đổi. Build tái sử dụng những gì có thể.

Con số thực tế từ dự án của tôi: build nguội mất ~55 giây, build đã cache mất ~15 giây. Đó là giảm 73%.

Docker Layer Caching#

Docker builds là nơi caching thực sự có tác động lớn. Một Docker build Next.js đầy đủ — cài OS deps, copy source, chạy pnpm install, chạy next build — mất 3-4 phút khi nguội. Với layer caching, chỉ 30-60 giây.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha sử dụng cache backend tích hợp của GitHub Actions. mode=max cache tất cả layers, không chỉ layers cuối cùng. Điều này quan trọng cho multi-stage builds nơi các layer trung gian (như pnpm install) tốn kém nhất để rebuild.

Turborepo Remote Cache#

Nếu bạn trong monorepo với Turborepo, remote caching mang tính cách mạng. Build đầu tiên upload task outputs lên cache. Các build tiếp theo download thay vì tính toán lại.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Tôi đã thấy thời gian CI monorepo giảm từ 8 phút xuống 90 giây với Turbo remote cache. Điều kiện: cần tài khoản Vercel hoặc Turbo server tự host. Với repo đơn ứng dụng, nó quá mức cần thiết.

Docker Build và Push#

Nếu bạn deploy lên VPS (hoặc bất kỳ server nào), Docker cho bạn builds có thể tái tạo. Cùng image chạy trong CI là cùng image chạy trong production. Không còn "nó chạy trên máy tôi" vì máy chính là image.

Multi-Stage Dockerfile#

Trước khi đến workflow, đây là Dockerfile tôi dùng cho Next.js:

dockerfile
# Giai đoạn 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Giai đoạn 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Giai đoạn 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Ba giai đoạn, phân tách rõ ràng. Image cuối cùng ~150MB thay vì ~1.2GB bạn sẽ có nếu copy mọi thứ. Chỉ artifacts production mới đến giai đoạn runner.

Workflow Build-và-Push#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Để tôi giải thích các quyết định quan trọng ở đây.

GitHub Container Registry (ghcr.io)#

Tôi dùng ghcr.io thay vì Docker Hub vì ba lý do:

  1. Authentication miễn phí. GITHUB_TOKEN tự động có sẵn trong mọi workflow — không cần lưu trữ credentials Docker Hub.
  2. Gần gũi. Images được pull từ cùng infrastructure nơi CI chạy. Pulls trong CI nhanh.
  3. Khả năng hiển thị. Images được liên kết với repo trong GitHub UI. Bạn thấy chúng trong tab Packages.

Multi-Platform Builds#

yaml
platforms: linux/amd64,linux/arm64

Dòng này thêm khoảng 90 giây vào build, nhưng đáng giá. Image ARM64 chạy native trên:

  • Apple Silicon Macs (M1/M2/M3/M4) khi phát triển local với Docker Desktop
  • AWS Graviton instances (rẻ hơn 20-40% so với tương đương x86)
  • Oracle Cloud tier ARM miễn phí

Không có điều này, developers trên M-series Macs đang chạy x86 images qua Rosetta emulation. Nó hoạt động, nhưng chậm hơn đáng kể và thỉnh thoảng xuất hiện bugs đặc thù kiến trúc.

QEMU cung cấp lớp cross-compilation. Buildx điều phối multi-arch build và push manifest list để Docker tự động pull đúng kiến trúc.

Chiến Lược Tagging#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Mỗi image nhận ba tags:

  • abc1234 (commit SHA): Bất biến. Bạn luôn có thể deploy một commit chính xác.
  • main (tên branch): Có thể thay đổi. Trỏ đến build mới nhất từ branch đó.
  • latest: Có thể thay đổi. Chỉ đặt trên default branch. Đây là thứ server của bạn pull.

Đừng bao giờ deploy latest trong production mà không ghi lại SHA ở đâu đó. Khi có gì đó hỏng, bạn cần biết latest nào. Tôi lưu SHA đã deploy trong file trên server mà health endpoint đọc.

SSH Deployment Tới VPS#

Đây là nơi mọi thứ kết hợp lại. CI pass, Docker image được build và push, giờ chúng ta cần bảo server pull image mới và restart.

SSH Action#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Pull image mới nhất
          docker pull "$IMAGE"
 
          # Dừng và xóa container cũ
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Khởi động container mới
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Chờ health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Ghi lại SHA đã deploy
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Dọn dẹp images cũ
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

Giải Pháp Thay Thế: Deploy Script#

Đối với bất cứ điều gì phức tạp hơn pull-và-restart đơn giản, tôi đưa logic vào script trên server thay vì inline trong workflow:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Đăng nhập GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull với retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Hàm health check
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Khởi động container mới trên port thay thế
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Xác minh container mới healthy
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Chuyển Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Dừng container cũ
docker stop akousa-app || true
docker rm akousa-app || true
 
# Đổi tên container mới
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

Workflow lúc đó trở thành một lệnh SSH duy nhất:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Cách này tốt hơn vì: (1) logic deploy được version-controlled trên server, (2) bạn có thể chạy thủ công qua SSH để debug, và (3) bạn không phải escape YAML bên trong YAML bên trong bash.

Chiến Lược Zero-Downtime#

"Zero downtime" nghe như marketing, nhưng nó có ý nghĩa chính xác: không request nào nhận được connection refused hay 502 trong quá trình deployment. Đây là ba cách tiếp cận thực tế, từ đơn giản nhất đến mạnh mẽ nhất.

Chiến Lược 1: PM2 Cluster Mode Reload#

Nếu bạn chạy Node.js trực tiếp (không trong Docker), cluster mode của PM2 cho bạn con đường zero-downtime dễ nhất.

bash
# ecosystem.config.js đã có:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (không phải restart) thực hiện rolling restart. Nó khởi động workers mới, chờ chúng sẵn sàng, rồi kill workers cũ từng cái một. Tại không thời điểm nào có zero workers đang phục vụ traffic.

Flag --update-env tải lại biến môi trường từ ecosystem config. Không có nó, env cũ vẫn tồn tại ngay cả sau deploy thay đổi .env.

Trong workflow:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Đây là cái tôi dùng cho trang web này. Đơn giản, đáng tin cậy, và downtime thực sự là zero — tôi đã test với load generator chạy 100 req/s trong khi deploys. Không một 5xx nào.

Chiến Lược 2: Blue/Green với Nginx Upstream#

Với Docker deployments, blue/green cho bạn phân tách rõ ràng giữa phiên bản cũ và mới.

Khái niệm: chạy container cũ ("blue") trên port 3000 và container mới ("green") trên port 3001. Nginx trỏ đến blue. Bạn khởi động green, xác minh nó healthy, chuyển Nginx sang green, rồi dừng blue.

Cấu hình Nginx upstream:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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;
        proxy_cache_bypass $http_upgrade;
    }
}

Script chuyển đổi:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Khởi động container mới trên port thay thế
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Chờ health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Chuyển Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Dừng container cũ
sleep 5  # Cho requests đang xử lý hoàn thành
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

5 giây sleep sau Nginx reload không phải lười biếng — đó là thời gian grace. Nginx reload là graceful (kết nối hiện tại được giữ mở), nhưng một số long-polling connections hoặc streaming responses cần thời gian để hoàn thành.

Chiến Lược 3: Docker Compose với Health Checks#

Cách tiếp cận có cấu trúc hơn, Docker Compose có thể quản lý blue/green swap:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

Dòng order: start-first là chìa khóa. Nó nghĩa là "khởi động container mới trước khi dừng container cũ." Kết hợp với parallelism: 1, bạn có rolling update — từng container một, luôn duy trì capacity.

Deploy với:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose theo dõi healthcheck và không route traffic đến container mới cho đến khi nó pass. Nếu healthcheck fail, failure_action: rollback tự động revert về phiên bản trước. Đây gần nhất với Kubernetes-style rolling deployments mà bạn có được trên một VPS đơn.

Quản Lý Secrets#

Quản lý secrets là một trong những thứ dễ làm "gần đúng" và sai thảm khốc ở các trường hợp còn lại.

GitHub Secrets: Cơ Bản#

yaml
# Đặt qua GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # Giá trị được che trong logs
      echo "Connecting to database..."
      # Dòng này sẽ in "Connecting to ***" trong logs
      echo "Connecting to $DB_URL"

GitHub tự động che giá trị secret khỏi log output. Nếu secret của bạn là p@ssw0rd123 và bất kỳ step nào in chuỗi đó, logs hiển thị ***. Hoạt động tốt, với một lưu ý: nếu secret của bạn ngắn (như PIN 4 số), GitHub có thể không che vì nó có thể khớp với chuỗi vô hại. Giữ secrets đủ phức tạp.

Secrets Theo Phạm Vi Environment#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Cùng tên secret, giá trị khác nhau theo environment. Trường environment trên job xác định bộ secrets nào được inject.

Production environments nên bật required reviewers. Điều này nghĩa là push lên main kích hoạt workflow, CI chạy tự động, nhưng deploy job tạm dừng và chờ ai đó click "Approve" trong GitHub UI. Với dự án cá nhân, có thể cảm thấy thừa. Với bất cứ thứ gì có người dùng, nó cứu mạng lần đầu bạn vô tình merge thứ gì đó hỏng.

OIDC: Không Còn Credentials Tĩnh#

Credentials tĩnh (AWS access keys, GCP service account JSON files) lưu trong GitHub Secrets là rủi ro. Chúng không hết hạn, không thể giới hạn cho một workflow run cụ thể, và nếu bị lộ, bạn phải rotate thủ công.

OIDC (OpenID Connect) giải quyết điều này. GitHub Actions đóng vai trò identity provider, và cloud provider của bạn tin tưởng nó phát hành credentials tạm thời ngay lập tức:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Bắt buộc cho OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Không access key. Không secret key. Action configure-aws-credentials yêu cầu token tạm thời từ AWS STS sử dụng OIDC token của GitHub. Token được giới hạn cho repo, branch và environment cụ thể. Nó hết hạn sau workflow run.

Thiết lập trên phía AWS yêu cầu IAM OIDC identity provider và role trust policy:

json
{
  "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"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

Điều kiện sub rất quan trọng. Không có nó, bất kỳ repo nào bằng cách nào đó có được thông tin OIDC provider của bạn có thể assume role. Với nó, chỉ branch main của repo cụ thể mới có thể.

GCP có thiết lập tương đương với Workload Identity Federation. Azure có federated credentials. Nếu cloud của bạn hỗ trợ OIDC, hãy dùng nó. Không có lý do gì để lưu cloud credentials tĩnh trong năm 2026.

SSH Keys Cho Deployment#

Với VPS deployments qua SSH, tạo key pair riêng:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Thêm public key vào ~/.ssh/authorized_keys của server với hạn chế:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Prefix restrict vô hiệu hóa port forwarding, agent forwarding, PTY allocation và X11 forwarding. Prefix command= nghĩa là key này chỉ có thể thực thi deploy script. Ngay cả khi private key bị lộ, kẻ tấn công chỉ có thể chạy deploy script và không gì khác.

Thêm private key vào GitHub Secrets dưới tên SSH_PRIVATE_KEY. Đây là một credential tĩnh duy nhất tôi chấp nhận — SSH keys với forced commands có blast radius rất hạn chế.

Workflow PR: Preview Deployments#

Mỗi PR xứng đáng có preview environment. Nó bắt lỗi visual mà unit tests bỏ sót, cho designers review mà không cần checkout code, và làm cuộc sống QA dễ dàng hơn đáng kể.

Deploy Preview Khi Mở PR#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Tìm comment hiện có
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

Phép tính port (4000 + PR_NUM) là một hack thực dụng. PR #42 nhận port 4042. Miễn là bạn không có hơn vài trăm PR mở, không có xung đột. Cấu hình Nginx wildcard route pr-*.preview.akousa.net đến đúng port.

Dọn Dẹp Khi Đóng PR#

Preview environments không được dọn dẹp sẽ ngốn disk và memory. Thêm cleanup job:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Required Status Checks#

Trong repository settings (Settings > Branches > Branch protection rules), yêu cầu các checks này trước khi merge:

  • lint — Không có lỗi lint
  • typecheck — Không có lỗi type
  • test — Tất cả tests pass
  • build — Dự án build thành công

Không có điều này, ai đó sẽ merge PR với checks failing. Không phải cố ý — họ sẽ thấy "2 trong 4 checks passed" và giả định hai cái kia vẫn đang chạy. Khóa chặt lại.

Cũng bật "Require branches to be up to date before merging." Điều này buộc chạy lại CI sau khi rebase lên main mới nhất. Nó bắt trường hợp hai PRs riêng lẻ pass CI nhưng xung đột khi kết hợp.

Thông Báo#

Deployment mà không ai biết là deployment mà không ai tin tưởng. Thông báo đóng vòng phản hồi.

Slack Webhook#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always() rất quan trọng. Không có nó, step thông báo bị bỏ qua khi deploy fail — chính xác là lúc bạn cần nó nhất.

GitHub Deployments API#

Để theo dõi deployment phong phú hơn, dùng GitHub Deployments API. Nó cho bạn lịch sử deployment trong repo UI và cho phép status badges:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... các bước deployment thực tế ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Giờ tab Environments trong GitHub hiển thị lịch sử deployment đầy đủ: ai deploy gì, khi nào, và thành công hay không.

Email Chỉ Khi Thất Bại#

Với các deployment quan trọng, tôi cũng kích hoạt email khi thất bại. Không qua email tích hợp của GitHub Actions (quá ồn ào), mà qua webhook có mục tiêu:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Đây là tuyến phòng thủ cuối cùng. Slack tốt nhưng cũng ồn ào — người ta mute channels. Email "DEPLOY FAILED" với link đến run sẽ được chú ý.

File Workflow Hoàn Chỉnh#

Đây là tất cả được nối lại thành một workflow duy nhất, sẵn sàng production. Rất gần với cái thực sự deploy trang web này.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, type check và test song song
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Chỉ sau khi CI pass
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: Build và push image (chỉ main branch)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH vào VPS và cập nhật
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Pull image mới
            docker pull "$IMAGE"
 
            # Chạy container mới trên port thay thế
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Chuyển traffic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Thời gian grace cho requests đang xử lý
            sleep 5
 
            # Dừng container cũ
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Đổi tên và reset port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Lưu ý: không reload Nginx ở đây vì tên container thay đổi,
            # không phải port. Deploy tiếp theo sẽ dùng đúng port.
 
            # Ghi lại deployment
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Dọn dẹp images cũ (hơn 7 ngày)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Duyệt Qua Luồng#

Khi tôi push lên main:

  1. Lint, Type Check và Test khởi động đồng thời. Ba runners, ba jobs song song. Nếu bất kỳ cái nào fail, pipeline dừng.
  2. Build chạy chỉ khi cả ba pass. Nó xác minh ứng dụng compile và tạo output hoạt động.
  3. Docker build image production và push lên ghcr.io. Multi-platform, layer-cached.
  4. Deploy SSH vào VPS, pull image mới, khởi động container mới, health-check nó, chuyển Nginx, và dọn dẹp.
  5. Thông báo gửi bất kể kết quả. Slack nhận tin nhắn. GitHub Deployments được cập nhật. Nếu thất bại, email cảnh báo được gửi.

Khi tôi mở PR:

  1. Lint, Type Check và Test chạy. Cùng quality gates.
  2. Build chạy để xác minh dự án compile.
  3. Docker và Deploy bị bỏ qua (điều kiện if giới hạn chúng chỉ cho branch main).

Khi tôi cần emergency deploy (bỏ qua tests):

  1. Click "Run workflow" trong tab Actions.
  2. Chọn skip_tests: true.
  3. Lint và typecheck vẫn chạy (bạn không thể bỏ qua — tôi không tin tưởng bản thân đến vậy).
  4. Tests bị bỏ qua, build chạy, Docker build, deploy kích hoạt.

Đây là workflow của tôi trong hai năm. Nó đã sống sót qua migrations server, nâng cấp major version Node.js, pnpm thay thế npm, và việc thêm 15 tools vào trang web này. Tổng thời gian end-to-end từ push đến production: 3 phút 40 giây trung bình. Bước chậm nhất là multi-platform Docker build ở ~90 giây. Mọi thứ khác được cache đến gần tức thì.

Bài Học Từ Hai Năm Cải Tiến#

Tôi sẽ kết thúc với những sai lầm tôi đã mắc để bạn không phải.

Pin phiên bản action. uses: actions/checkout@v4 thì ổn, nhưng cho production, cân nhắc uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (SHA đầy đủ). Một action bị xâm phạm có thể đánh cắp secrets của bạn. Sự cố tj-actions/changed-files năm 2025 đã chứng minh đây không phải lý thuyết.

Đừng cache mọi thứ. Tôi đã từng cache node_modules trực tiếp (không phải chỉ pnpm store) và mất hai giờ debug lỗi build ảo gây ra bởi native bindings cũ. Cache package manager store, không phải installed modules.

Đặt timeouts. Mỗi job nên có timeout-minutes. Mặc định là 360 phút (6 giờ). Nếu deploy của bạn bị treo vì SSH connection bị drop, bạn không muốn phát hiện ra sáu giờ sau khi đã tiêu hết ngân sách phút hàng tháng.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Dùng concurrency khôn ngoan. Với PRs, cancel-in-progress: true luôn đúng — không ai quan tâm kết quả CI của commit đã bị force-pushed qua. Với production deploys, đặt thành false. Bạn không muốn commit theo sau nhanh hủy deploy đang giữa chừng rollout.

Test file workflow. Dùng act (https://github.com/nektos/act) để chạy workflows locally. Nó không bắt được mọi thứ (secrets không có sẵn, và môi trường runner khác biệt), nhưng bắt lỗi cú pháp YAML và lỗi logic rõ ràng trước khi bạn push.

Giám sát chi phí CI. GitHub Actions minutes miễn phí cho repo public và rẻ cho private, nhưng chúng cộng dồn. Multi-platform Docker builds tốn 2x minutes (mỗi platform một). Matrix test strategies nhân đôi runtime. Theo dõi trang billing.

CI/CD pipeline tốt nhất là cái bạn tin tưởng. Tin tưởng đến từ độ tin cậy, khả năng quan sát, và cải tiến dần dần. Bắt đầu với pipeline lint-test-build đơn giản. Thêm Docker khi cần tái tạo được. Thêm SSH deployment khi cần tự động hóa. Thêm thông báo khi cần sự tự tin. Đừng build pipeline đầy đủ ngay ngày đầu — bạn sẽ thiết kế sai abstractions.

Hãy build pipeline bạn cần hôm nay, và để nó phát triển cùng dự án.

Bài viết liên quan