Trending
7 phút đọc15 tháng 6, 2026249

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.

N

Nguyễn Nhật Long

@nguyennhatlong1303

GitHub Actions + Docker: Setup CI/CD Pipeline Từ A Đến Z

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:

YAML
1name: Build & Deploy
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9jobs:
10 build:
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v4
14
15 - uses: docker/login-action@v3
16 with:
17 registry: ghcr.io
18 username: ${{ github.actor }}
19 password: ${{ secrets.GITHUB_TOKEN }}
20
21 - uses: docker/build-push-action@v5
22 with:
23 push: true
24 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:

YAML
1- uses: docker/build-push-action@v5
2 with:
3 push: true
4 tags: ghcr.io/your-username/your-app:latest
5 cache-from: type=registry,ref=ghcr.io/your-username/your-app:buildcache
6 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:

YAML
1- uses: actions/cache@v3
2 with:
3 path: /tmp/.buildx-cache
4 key: ${{ runner.os }}-buildx-${{ github.sha }}
5 restore-keys: |
6 ${{ runner.os }}-buildx-
7
8- uses: docker/build-push-action@v5
9 with:
10 cache-from: type=local,src=/tmp/.buildx-cache
11 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.

YAML
1- uses: docker/setup-qemu-action@v3
2
3- uses: docker/setup-buildx-action@v3
4
5- uses: docker/build-push-action@v5
6 with:
7 platforms: linux/amd64,linux/arm64
8 push: true
9 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.

RegistryFree tierRate limitTích hợp tốt vớiGhi chú
Docker Hub1 private repoCó (100 pulls/6h với anonymous)Mọi nơiPhổ biến nhất, nhưng rate limit gây đau đầu
GitHub Container Registry (ghcr.io)Unlimited public, free với GitHubKhôngGitHub ActionsMình đang dùng cái này cho hầu hết project
AWS ECR500MB free/thángKhôngAWS ECS, EKS, LambdaPhải dùng nếu deploy trên AWS
Google Artifact Registry0.5GB freeKhôngGCP servicesThay thế cho GCR cũ
Harbor (self-hosted)UnlimitedKhôngMọi nơiCầ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):

Terminal
1ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions
2cat ~/.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_HOSTVPS_USER vào secrets luôn.

Workflow hoàn chỉnh:

YAML
1name: Build & Deploy Next.js
2
3on:
4 push:
5 branches: [main]
6
7jobs:
8 build-and-deploy:
9 runs-on: ubuntu-latest
10
11 steps:
12 - uses: actions/checkout@v4
13
14 - uses: docker/setup-buildx-action@v3
15
16 - uses: docker/login-action@v3
17 with:
18 registry: ghcr.io
19 username: ${{ github.actor }}
20 password: ${{ secrets.GITHUB_TOKEN }}
21
22 - uses: docker/build-push-action@v5
23 with:
24 context: .
25 push: true
26 platforms: linux/amd64
27 tags: |
28 ghcr.io/${{ github.repository }}:latest
29 ghcr.io/${{ github.repository }}:${{ github.sha }}
30 cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
31 cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
32
33 - name: Deploy to VPS
34 uses: appleboy/ssh-action@v1
35 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 }}:latest
41 docker stop nextjs-app || true
42 docker rm nextjs-app || true
43 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:

Terminal
1# Trên VPS, thay đổi phần deploy script
2docker pull ghcr.io/${{ github.repository }}:latest
3
4# Start container mới trên port khác
5docker 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 }}:latest
12
13# Chờ healthy
14until [ "$(docker inspect --format='{{.State.Health.Status}}' nextjs-app-new)" = "healthy" ]; do
15 sleep 2
16done
17
18# Switch traffic (nếu dùng nginx)
19nginx -s reload
20
21# Stop container cũ
22docker stop nextjs-app && docker rm nextjs-app
23docker 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:

YAML
1- name: Run Trivy vulnerability scanner
2 uses: aquasecurity/trivy-action@master
3 with:
4 image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
5 format: table
6 exit-code: 1
7 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:

YAML
1- uses: docker/metadata-action@v5
2 id: meta
3 with:
4 images: ghcr.io/${{ github.repository }}
5 tags: |
6 type=ref,event=branch
7 type=semver,pattern={{version}}
8 type=sha
9
10- uses: docker/build-push-action@v5
11 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.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễ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è!

GitHub Actions + Docker: Setup CI/CD Pipeline Từ A Đến Z — Stacklog