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.
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#
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
- productionBốn trigger, mỗi cái phục vụ mục đích khác nhau:
pushvàomainlà trigger deploy production. Code được merge? Ship thôi.pull_requestchạy các CI check trên mỗi PR. Đây là nơi lint, type check và tests nằm.schedulelà 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_dispatchcho 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#
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 lintJobs 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#
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ể.
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: 1Tạ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:
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 testfail-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#
- 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:
- 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.
- 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à:
- 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ì.
- 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.
- 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.
- 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=maxtype=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.
- 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:
# 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#
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:
- Authentication miễn phí.
GITHUB_TOKENtự động có sẵn trong mọi workflow — không cần lưu trữ credentials Docker Hub. - Gần gũi. Images được pull từ cùng infrastructure nơi CI chạy. Pulls trong CI nhanh.
- 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#
platforms: linux/amd64,linux/arm64Dò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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shCá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.
# ecosystem.config.js đã có:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# Đặ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#
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.netCù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:
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.comKhô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:
{
"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:
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#
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:
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 linttypecheck— Không có lỗi typetest— Tất cả tests passbuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueDuyệt Qua Luồng#
Khi tôi push lên main:
- 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.
- Build chạy chỉ khi cả ba pass. Nó xác minh ứng dụng compile và tạo output hoạt động.
- Docker build image production và push lên ghcr.io. Multi-platform, layer-cached.
- 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.
- 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:
- Lint, Type Check và Test chạy. Cùng quality gates.
- Build chạy để xác minh dự án compile.
- Docker và Deploy bị bỏ qua (điều kiện
ifgiới hạn chúng chỉ cho branchmain).
Khi tôi cần emergency deploy (bỏ qua tests):
- Click "Run workflow" trong tab Actions.
- Chọn
skip_tests: true. - 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).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestDù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.