GitHub Actions + Docker: Setup CI/CD Pipeline Từ A Đến Z
Automate toàn bộ quy trình build và deploy Docker image với GitHub Actions từ multi-platform builds đến zero-downtime deployment trên VPS.
Nguyễn Nhật Long
@nguyennhatlong1303
Nếu bạn đã đọc các bài trước trong series này, giờ app Docker của bạn đã được hardening khá ổn cho production rồi. Nhưng mỗi lần deploy vẫn phải SSH lên server, chạy docker pull, restart container bằng tay cái flow đó vừa chậm vừa dễ sai. Bài này mình sẽ automate toàn bộ cái đó đi.
Mục tiêu cuối bài: push code lên main là mọi thứ tự chạy build image, push lên registry, deploy lên server. Bạn chỉ cần git push rồi đi pha cà phê.
Cấu trúc cơ bản của một GitHub Actions workflow
Tạo file .github/workflows/docker.yml trong repo. GitHub sẽ tự detect và chạy khi có trigger phù hợp.
Một workflow đơn giản nhất trông như này:
1name: Build & Deploy23on:4 push:5 branches: [main]6 pull_request:7 branches: [main]89jobs:10 build:11 runs-on: ubuntu-latest12 steps:13 - uses: actions/checkout@v41415 - uses: docker/login-action@v316 with:17 registry: ghcr.io18 username: ${{ github.actor }}19 password: ${{ secrets.GITHUB_TOKEN }}2021 - uses: docker/build-push-action@v522 with:23 push: true24 tags: ghcr.io/your-username/your-app:latest
Lưu ý GITHUB_TOKEN là secret được GitHub tự inject vào bạn không cần tạo thủ công. Còn nếu push lên Docker Hub hay AWS ECR thì cần tạo secret riêng trong repo settings.
Flow cơ bản là: checkout code → login vào registry → build image → push. Đơn giản vậy thôi, nhưng thực tế thì có vài thứ cần chú ý thêm để nó chạy nhanh và ổn định.
Cache layers cái này quan trọng hơn bạn nghĩ
Build Docker image từ đầu mỗi lần CI chạy thì cực kỳ chậm, đặc biệt với Next.js hay các project có nhiều dependencies. Theo kinh nghiệm của mình, enable caching đúng cách có thể giảm build time từ 8-10 phút xuống còn 1-2 phút.
Có mấy approach:
Inline cache với BuildKit đơn giản nhất, cache được lưu ngay trong image:
1- uses: docker/build-push-action@v52 with:3 push: true4 tags: ghcr.io/your-username/your-app:latest5 cache-from: type=registry,ref=ghcr.io/your-username/your-app:buildcache6 cache-to: type=registry,ref=ghcr.io/your-username/your-app:buildcache,mode=max
GitHub Actions cache dùng actions/cache để cache Docker layers locally trên runner:
1- uses: actions/cache@v32 with:3 path: /tmp/.buildx-cache4 key: ${{ runner.os }}-buildx-${{ github.sha }}5 restore-keys: |6 ${{ runner.os }}-buildx-78- uses: docker/build-push-action@v59 with:10 cache-from: type=local,src=/tmp/.buildx-cache11 cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
Mình thấy cái registry cache hay hơn vì nó persist giữa các runner khác nhau, còn local cache thì phụ thuộc vào runner instance GitHub cấp cho bạn không đảm bảo cùng machine.
Multi-platform builds một lần build, chạy khắp nơi
Nếu team bạn có người dùng Mac M1/M2 mà server chạy x86, hoặc bạn đang xem xét chuyển sang AWS Graviton (ARM) để tiết kiệm chi phí, thì multi-platform build là thứ bạn cần.
1- uses: docker/setup-qemu-action@v323- uses: docker/setup-buildx-action@v345- uses: docker/build-push-action@v56 with:7 platforms: linux/amd64,linux/arm648 push: true9 tags: ghcr.io/your-username/your-app:latest
QEMU emulation cho phép GitHub Actions runner (x86) build image cho ARM. Build sẽ chậm hơn một chút do emulation, nhưng bạn chỉ cần maintain một Dockerfile duy nhất. Mình thấy cái này hay ở chỗ: khi push image lên registry, Docker tự tạo manifest list người dùng pull về thì Docker client tự chọn đúng platform, transparent hoàn toàn.
Chọn registry nào?
Với cá nhân và startup nhỏ, ghcr.io là lựa chọn mặc định của mình free, không rate limit, tích hợp GitHub Actions cực mượt với GITHUB_TOKEN có sẵn.
| Registry | Free tier | Rate limit | Tích hợp tốt với | Ghi chú |
|---|---|---|---|---|
| Docker Hub | 1 private repo | Có (100 pulls/6h với anonymous) | Mọi nơi | Phổ biến nhất, nhưng rate limit gây đau đầu |
| GitHub Container Registry (ghcr.io) | Unlimited public, free với GitHub | Không | GitHub Actions | Mình đang dùng cái này cho hầu hết project |
| AWS ECR | 500MB free/tháng | Không | AWS ECS, EKS, Lambda | Phải dùng nếu deploy trên AWS |
| Google Artifact Registry | 0.5GB free | Không | GCP services | Thay thế cho GCR cũ |
| Harbor (self-hosted) | Unlimited | Không | Mọi nơi | Cần maintain infra, phù hợp enterprise |
Hands-on: Auto-deploy Next.js lên VPS
Đây là phần thực tế nhất. Mình sẽ walk through toàn bộ flow: push code → build → deploy, không cần tay chân gì cả.
Chuẩn bị trên VPS:
Bạn cần tạo SSH key riêng cho GitHub Actions (đừng dùng key cá nhân):
1ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions2cat ~/.ssh/github_actions.pub >> ~/.ssh/authorized_keys
Sau đó copy nội dung ~/.ssh/github_actions (private key) vào GitHub repo secrets với tên VPS_SSH_KEY. Thêm VPS_HOST và VPS_USER vào secrets luôn.
Workflow hoàn chỉnh:
1name: Build & Deploy Next.js23on:4 push:5 branches: [main]67jobs:8 build-and-deploy:9 runs-on: ubuntu-latest1011 steps:12 - uses: actions/checkout@v41314 - uses: docker/setup-buildx-action@v31516 - uses: docker/login-action@v317 with:18 registry: ghcr.io19 username: ${{ github.actor }}20 password: ${{ secrets.GITHUB_TOKEN }}2122 - uses: docker/build-push-action@v523 with:24 context: .25 push: true26 platforms: linux/amd6427 tags: |28 ghcr.io/${{ github.repository }}:latest29 ghcr.io/${{ github.repository }}:${{ github.sha }}30 cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache31 cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max3233 - name: Deploy to VPS34 uses: appleboy/ssh-action@v135 with:36 host: ${{ secrets.VPS_HOST }}37 username: ${{ secrets.VPS_USER }}38 key: ${{ secrets.VPS_SSH_KEY }}39 script: |40 docker pull ghcr.io/${{ github.repository }}:latest41 docker stop nextjs-app || true42 docker rm nextjs-app || true43 docker run -d \44 --name nextjs-app \45 --restart unless-stopped \46 -p 3000:3000 \47 --health-cmd="curl -f http://localhost:3000/api/health || exit 1" \48 --health-interval=30s \49 --health-retries=3 \50 ghcr.io/${{ github.repository }}:latest
Mình tag image với cả latest và commit SHA cái này quan trọng để rollback. Nếu deploy xong có bug, bạn chỉ cần SSH lên và docker run với tag của commit trước đó.
Zero-downtime với health checks
Cái script deploy ở trên có một vấn đề: có một khoảng thời gian ngắn giữa docker stop và container mới start lên, app sẽ down. Với traffic thấp thì không sao, nhưng production thực thì cần xử lý tốt hơn.
Cách đơn giản nhất là dùng rolling update approach:
1# Trên VPS, thay đổi phần deploy script2docker pull ghcr.io/${{ github.repository }}:latest34# Start container mới trên port khác5docker run -d \6 --name nextjs-app-new \7 -p 3001:3000 \8 --health-cmd="curl -f http://localhost:3000/api/health || exit 1" \9 --health-interval=5s \10 --health-retries=5 \11 ghcr.io/${{ github.repository }}:latest1213# Chờ healthy14until [ "$(docker inspect --format='{{.State.Health.Status}}' nextjs-app-new)" = "healthy" ]; do15 sleep 216done1718# Switch traffic (nếu dùng nginx)19nginx -s reload2021# Stop container cũ22docker stop nextjs-app && docker rm nextjs-app23docker rename nextjs-app-new nextjs-app
Nếu bạn không muốn tự viết cái này, Coolify hoặc Dokku là hai lựa chọn mình hay recommend cho anh em. Coolify đặc biệt ngon self-hosted Heroku basically, có UI đẹp, handle zero-downtime deployment tự động, support Docker Compose, và free hoàn toàn. Bạn chỉ cần deploy Coolify lên VPS một lần, sau đó mọi thứ đều qua UI hoặc webhook.
Một vài thứ hay bị bỏ qua
Anh em lưu ý mấy điểm này khi setup:
Scan vulnerabilities trước khi push thêm bước này vào workflow:
1- name: Run Trivy vulnerability scanner2 uses: aquasecurity/trivy-action@master3 with:4 image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}5 format: table6 exit-code: 17 severity: CRITICAL
Nếu image có CRITICAL vulnerability, workflow sẽ fail và không deploy. Cái này save bạn khỏi nhiều tình huống khó xử.
Tag theo semver thay vì chỉ dùng latest latest không nên là thứ duy nhất bạn dùng trong production. Kết hợp với docker/metadata-action để tự động generate tags:
1- uses: docker/metadata-action@v52 id: meta3 with:4 images: ghcr.io/${{ github.repository }}5 tags: |6 type=ref,event=branch7 type=semver,pattern={{version}}8 type=sha910- uses: docker/build-push-action@v511 with:12 tags: ${{ steps.meta.outputs.tags }}
Separate build và deploy jobs nếu bạn có nhiều environment (staging, production), tách thành hai job riêng. Job build chạy cho mọi PR, job deploy chỉ chạy khi merge vào main. Điều này giúp bạn catch build errors sớm ngay từ PR stage.
Thực ra setup CI/CD cho Docker không quá phức tạp phần khó nhất thường là lần đầu config secrets và hiểu flow của GitHub Actions. Một khi đã chạy được rồi, bạn sẽ không muốn quay lại deploy tay nữa đâu. Mình nhớ lần đầu setup xong, ngồi nhìn pipeline chạy tự động mà cảm giác rất thỏa mãn kiểu như đã dạy máy làm việc thay mình vậy.
Bài tiếp theo trong series mình sẽ đi vào Docker Compose cho production orchestrate nhiều services, handle secrets đúng cách, và monitoring stack cơ bản với Prometheus + Grafana.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!