Sổ tay kỹ thuật / AI Engineering Notebook
AI Engineer
Blog
BlogCI/CD: đưa website lên VPS qua GitHub Actions và GHCR
Hệ thốngCI/CDTrung bình

CI/CD: đưa website lên VPS qua GitHub Actions và GHCR

Theo một lần push đi hết pipeline: CI kiểm tra nhanh, CD đóng gói một image rồi đẩy lên registry, và server chỉ kéo đúng image đó về chạy. Vì sao build một lần, và làm sao truy ngược đúng phiên bản.

Nguyên25 tháng 8, 202612 phút đọcBài 02 / 05
CI/CD: đưa website lên VPS qua GitHub Actions và GHCR

Theo một lần push đi hết pipeline: CI kiểm tra nhanh, CD đóng gói một image rồi đẩy lên registry, và server chỉ kéo đúng image đó về chạy. Vì sao build một lần, và làm sao truy ngược đúng phiên bản.

Chữ "CI/CD" thì ai làm web cũng nghe. Chỗ hay mơ hồ là luồng chạy thật: máy nào làm việc gì, vì sao code bị build tới hai lần, và làm sao để server chạy đúng y hệt bản code đã qua kiểm tra chứ không phải một bản gần giống. Bài này đi theo một lần git push từ đầu tới lúc site cập nhật.

Lấy trang bạn đang đọc làm ví dụ. Giống phần lớn dự án, mỗi lần đẩy code lên GitHub, bản chạy thật trên server tự cập nhật theo, không ai phải SSH vào gõ tay. Toàn bộ việc đó do một pipeline lo, và nó gọn hơn bạn tưởng.

The full pipeline: a git push triggers GitHub Actions on a throwaway runner. CI runs fast pass/fail checks; CD builds a Docker image and pushes it to the GHCR registry. The deploy step then SSHes into the VPS, which pulls that exact image and runs it.
The full pipeline: a git push triggers GitHub Actions on a throwaway runner. CI runs fast pass/fail checks; CD builds a Docker image and pushes it to the GHCR registry. The deploy step then SSHes into the VPS, which pulls that exact image and runs it.

Cách kể của bài: đọc thẳng các file cấu hình thật của pipeline (ci.yml, cd.yml và file compose dành cho server) rồi giải thích từng khối. Chuyện dựng hạ tầng lần đầu (tạo khóa, bấm dashboard) không phải trọng tâm.

1. CI và CD làm hai việc khác nhau

Hai chữ hay đi cùng nhau nhưng đứng ở hai chặng khác hẳn.

CI (Continuous Integration): chặng kiểm tra đúng sai nhanh, chạy mỗi khi có code mới. Nó trả lời "code build được, chạy được không", trên một máy ảo tạm rồi vứt đi, không đụng tới server thật.

CD (Continuous Delivery/Deployment): chặng tạo ra sản phẩm giao hàng thật rồi làm cho nó chạy trên server. Đây là phần đưa code đã kiểm ra ngoài đời.

Thắc mắc nảy ra ngay: CI đã build rồi, sao CD build lại? Vì hai lần build phục vụ hai mục đích. CI build kiểu native (chạy thẳng uv, npm) chỉ để trả lời "code có ổn không" cho thật nhanh. CD build ra một Docker image, tức một bản đóng gói đem đi chạy nơi khác được. Không thừa, mà là hai vai.

Đây là file CI, rút gọn còn phần cốt lõi:

yaml
name: CI
on:
  push:
    branches: [main]
    paths-ignore: ['content/**', 'docs/**', '**/*.md']
  pull_request:

jobs:
  backend:
    runs-on: ubuntu-latest
    defaults:
      run: { working-directory: backend }
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - run: uv sync
      - run: uv run python -c "import app.main; print('backend import ok')"

  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install
      - run: npm run build

Đọc theo cấu hình:

  • on: là điều kiện kích hoạt: chạy khi push vào main và khi mở pull request. paths-ignore: lọc bớt, sửa mỗi chữ (thư mục nội dung, tài liệu, file .md) thì bỏ qua CI cho đỡ tốn, code thì vẫn chạy.
  • runs-on: ubuntu-latest là máy ảo tạm của GitHub, không phải VPS. Đây là điểm mấu chốt của CI: mượn máy của GitHub kiểm tra, xong hủy máy.

Job: một đơn vị việc chạy trên một máy ảo riêng. Ở đây hai job backendfrontend chạy song song trên hai máy ảo tách biệt.

  • Phép kiểm tra của backend là một smoke import: import app.main chạy lọt nghĩa là app nạp không lỗi. Của frontend là npm run build: build xong nghĩa là type-check và biên dịch cả app đều ổn.

Cả hai đều là kiểm tra native, nhanh gọn. Việc đóng gói image để ship dành cho CD.

2. Registry: chỗ bàn giao giữa hai máy

Trước khi đọc CD, cần thấy vì sao có một cái kho ở giữa. Máy CI là máy tạm, xong việc là biến mất. Server thì cần một thứ chạy được. Nối hai đầu đó là một registry.

Docker image: một ảnh chụp đóng băng gồm hệ điều hành nền, thư viện, mã đã build và nội dung, gói kín trong một khối. Thứ được kiểm thử chính là thứ đem chạy, giống hệt từng byte. Đó là cách xóa sổ câu "máy tôi chạy được mà".

Registry: kho chứa image, đánh địa chỉ bằng tên và tag. Ở đây dùng GitHub Container Registry (GHCR). Luồng đi: pipeline build image rồi đẩy (push) lên registry, server chỉ kéo (pull) về.

Một câu tóm gọn cả kiến trúc: build once, run anywhere. Image build đúng một lần trên máy CI. Server không bao giờ tự build, nên không tốn CPU và RAM của máy chủ cho việc build, cũng không có chuyện "server build ra bản hơi khác". Nó chỉ tải về đúng cái khối đã được kiểm thử rồi chạy.

3. Đúng phiên bản và truy ngược được: tag bằng git SHA

Còn một câu hỏi quan trọng: image nào ứng với code nào? Nếu chỉ có một cái nhãn latest luôn trỏ về bản mới nhất, thì khi bản mới hỏng, bạn chẳng có gì để lùi về, cũng không biết bản đang chạy là commit nào.

Tag: cái nhãn dán lên một phiên bản image, và một image dán được nhiều nhãn cùng lúc.

Cách làm: mỗi image mang hai nhãn. Một là git SHA của commit (chuỗi băm nhận diện đúng một commit), bất biến, gắn cứng vào đúng bản code đó. Hai là latest, di động, luôn trỏ về bản mới nhất.

One image, two tags. The newest commit (a1b2c3d) is built once into a single image. That image gets an immutable SHA tag (:a1b2c3d, pinned to that one commit forever) and a moving :latest tag. Deploy pins the SHA tag so the server runs the exact tested bytes; rollback just points the server back at an older SHA tag.
One image, two tags. The newest commit (a1b2c3d) is built once into a single image. That image gets an immutable SHA tag (:a1b2c3d, pinned to that one commit forever) and a moving :latest tag. Deploy pins the SHA tag so the server runs the exact tested bytes; rollback just points the server back at an older SHA tag.

Cái nhãn SHA là chỗ đáng học nhất:

  • Đúng phiên bản: bước deploy trỏ server về đúng tag SHA của commit vừa push, nên server chạy đúng những byte mà CI đã kiểm thử, không phải "bản mới nhất tình cờ đang là gì đó".
  • Truy ngược: nhìn tag SHA của image đang chạy là biết ngay nó sinh ra từ commit nào.
  • Rollback gọn: muốn quay lại bản cũ, chỉ cần trỏ server về tag SHA cũ, không phải build lại hay sửa gì.

4. Đọc cd.yml phần build: dựng image và đẩy lên GHCR

File CD có hai job. Job đầu build và đẩy image, rút gọn:

yaml
name: CD
on:
  push:
    branches: [main]
    paths-ignore: ['content/**', 'docs/**', '**/*.md']

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write # cho GITHUB_TOKEN quyền đẩy lên GHCR
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: ./backend
          push: true
          tags: |
            ghcr.io/<owner>/aie-blog-api:latest
            ghcr.io/<owner>/aie-blog-api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      # ... một khối tương tự cho ./frontend -> aie-blog-web

Đọc theo cấu hình:

  • permissions: packages: write cấp cho token mặc định của GitHub (GITHUB_TOKEN) quyền đẩy image lên GHCR. Token này GitHub tự tạo cho mỗi lần chạy, không phải khóa mình tự quản.
  • login-action đăng nhập registry ghcr.io bằng chính token đó.
  • build-push-action với context: ./backend dựng image từ Dockerfile trong thư mục backend rồi đẩy lên. Khối tags: chính là hai nhãn ở mục 3; ${{ github.sha }} là biến GitHub tự điền bằng SHA của commit đang build.
  • cache-from/cache-to: type=gha dùng bộ nhớ đệm của GitHub Actions để tái dùng các lớp Docker giữa các lần build, nên build sau nhanh hơn.

Một khối y hệt lặp lại cho ./frontend, ra image aie-blog-web. Hết job này, hai image của đúng commit đó đã nằm trên GHCR, chưa gì chạm tới VPS.

5. File compose cho server khác file dev ở đâu

Server chạy stack theo một file compose riêng cho môi trường thật, khác với file compose lúc dev (đọc ở bài Docker). Rút gọn:

yaml
services:
  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    image: ghcr.io/<owner>/aie-blog-api:${IMAGE_TAG:-latest}
    restart: unless-stopped
    depends_on:
      db: { condition: service_healthy }

  web:
    image: ghcr.io/<owner>/aie-blog-web:${IMAGE_TAG:-latest}
    restart: unless-stopped

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    depends_on: [web]

volumes:
  db_data:

Bốn khác biệt cốt lõi so với file dev, đọc theo cấu hình:

  1. Nguồn image: apiweb ghi image: ghcr.io/... (dùng image dựng sẵn) thay vì build: ./... (dựng tại chỗ). Server không cần mã nguồn.
  2. Chọn đúng phiên bản: ${IMAGE_TAG:-latest} nghĩa là lấy biến IMAGE_TAG nếu có, không thì mặc định latest. Bước deploy sẽ đặt biến này bằng git SHA để chạy đúng bản của commit.
  3. Tự hồi phục: restart: unless-stopped để container tự dậy khi crash hay khi VPS reboot. File dev không cần.
  4. Chờ phụ thuộc thật sự sẵn sàng: healthcheck cộng depends_on: condition: service_healthy khiến api chỉ khởi động sau khi db thực sự nhận kết nối, tránh vòng lặp crash lúc mới bật.

Ngoài ra file này không mở cổng nào ra ngoài; đường ra Internet duy nhất là service cloudflared. Cơ chế của nó nằm ở bài Cloudflare Tunnel; ở đây chỉ cần biết nó là cửa duy nhất ra Internet. Một câu để nhớ: file dev là "dựng từ mã nguồn, mở cổng cho tôi xem"; file server là "chạy image đóng băng, không hở cổng, tự hồi phục".

6. Đọc cd.yml phần deploy: SSH sai server chạy image mới

Job thứ hai chạy sau khi build xong, rút gọn:

yaml
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Copy the compose file to the server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          source: compose.prod.yml
          target: /home/${{ secrets.VPS_USER }}/aie-blog
      - name: Pull images and restart the stack
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd ~/aie-blog
            export IMAGE_TAG=${{ github.sha }}
            docker compose -f compose.prod.yml --env-file .env.prod pull
            docker compose -f compose.prod.yml --env-file .env.prod up -d
            docker image prune -a -f

Đọc theo cấu hình:

  • needs: build-and-push bắt job này chỉ chạy sau khi build thành công. Không có image thì không deploy.
  • scp-action copy file compose mới nhất từ repo lên server, để định nghĩa stack trên server không bao giờ lệch với repo. Còn file bí mật chứa mật khẩu (.env.prod) thì đã nằm sẵn trên server từ trước, không vào Git.
  • ssh-action mở một phiên SSH vào server rồi chạy vài lệnh. Điểm cốt lõi: mọi việc phát ra từ máy ảo GitHub qua SSH, server chỉ nhận lệnh.
  • export IMAGE_TAG=${{ github.sha }} đặt biến để compose kéo đúng image của commit này, nên server chạy đúng những byte CI đã kiểm thử (chính là mục 3).
  • pull rồi up -d: kéo image mới về rồi dựng lại stack theo trạng thái mong muốn. docker compose so cấu hình mới với cái đang chạy, chỉ thay container nào thực sự đổi (web và api có tag SHA mới nên bị thay; db và cloudflared không đổi nên đứng yên). Luôn chỉ có đúng một bộ container.
  • docker image prune -a -f xóa các image SHA cũ không còn container nào dùng, để đĩa không phình dần. Phải có cờ -a vì image cũ vẫn còn tag SHA nên không tính là "dangling".

7. Vì sao dùng SSH key riêng cho deploy

Ở bước deploy có mấy ${{ secrets.VPS_... }}. Đằng sau chúng là một quyết định bảo mật đáng hiểu.

Secret (GitHub): một giá trị nhạy cảm cất mã hóa trên server GitHub, chỉ được giải mã bên trong lúc pipeline chạy. Máy dev của mình chỉ là bàn điều khiển để nạp nó lên, không giữ bản sao.

Để pipeline SSH được vào server, nó cần một cặp khóa SSH. Điểm mấu chốt: không dùng khóa cá nhân của mình, mà tạo một cặp khóa deploy riêng chỉ để pipeline vào server. Nửa công khai đặt trên server; nửa bí mật cất trong kho secret của GitHub. Lợi ích: nếu khóa deploy lộ, chỉ cần thu hồi đúng nó, không đụng tới khóa cá nhân.

Gói lại ba máy ba vai cho khỏi rối: máy dev (nơi ra lệnh quản trị), server GitHub (giữ code, giữ secret, chạy pipeline), và VPS (nơi site chạy thật). Khi push, một máy ảo của GitHub đọc secret, cầm private key SSH vào VPS; VPS thấy khớp public key nên cho vào rồi chạy lệnh deploy.

8. Vài chỗ dễ hiểu nhầm

Vì sao secret phải nằm trên GitHub chứ không phải máy mình? Vì pipeline chạy trên máy của GitHub, nó không với tới file khóa trong máy local. Đối lập cho dễ nhớ: biến môi trường local nằm ở máy mình và GitHub không bao giờ thấy; còn secret nằm trên GitHub và chỉ giải mã lúc pipeline chạy.

Vì sao đẩy image thì được mà kéo lại cần quyền khác? Mỗi token có một danh sách quyền riêng. Token trong pipeline có quyền ghi packages để đẩy image; còn để server kéo image private về thì cần quyền đọc packages. Nên server đăng nhập GHCR bằng một token có đúng scope đọc, tách khỏi token của pipeline.

GHCR mà sao toàn lệnh docker? Vì docker là client vạn năng cho mọi registry theo chuẩn OCI. GHCR, Docker Hub, AWS ECR đều nói cùng một giao thức, nên cùng bộ lệnh docker login, docker pull, docker push chạy được với bất kỳ cái nào, chỉ đổi phần hostname ở đầu. Chữ docker ở đây là công cụ và giao thức, không phải công ty.

9. Kết quả

Ghép lại, một lần push lên main giờ sẽ: chạy CI kiểm đúng sai, build hai image và đẩy lên GHCR với tag SHA cộng latest, rồi job deploy SSH vào server kéo đúng image của commit đó và dựng lại stack. Các container lên đúng thứ tự (db chờ khỏe rồi mới tới api, cùng web và cloudflared), site ra Internet qua tunnel. Điều rút ra: từ giờ chỉ cần push, website tự cập nhật, server không build gì, chỉ kéo đúng bản đã kiểm thử, và luôn truy ngược được bản đang chạy là commit nào.

10. Liên quan

Bài này là mắt xích hạ tầng, đọc pipeline đưa stack ra chạy thật. Nó nối với: