AI Automation

Triển khai CI/CD lên VPS với GitHub Actions và SSH

Bạn vừa push code lên GitHub, nhưng vẫn phải SSH vào VPS, kéo code, build lại, khởi động lại service? Nếu deploy thủ công nhiều lần trong ngày, vừa mất thời gian vừa dễ sai. Bài viết này hướng dẫn thiết lập pipeline CI/CD dùng GitHub Actions để tự động deploy code lên VPS Linux qua SSH, mỗi lần push là code tự lên server, không cần chạm tay.

Tóm tắt nhanh

  • GitHub Actions đọc workflow từ file YAML trong repo, chạy trên runner của GitHub (hoặc self-hosted).
  • SSH deploy dùng action appleboy/ssh-action, chạy lệnh từ xa trên VPS.
  • Dùng deploy key hoặc SSH key riêng, lưu trong GitHub Secrets, không hardcode trong code.
  • Pipeline điển hình: push → chạy test → build (nếu cần) → SSH vào VPS → pull code mới → restart service.
  • Cấu hình zero-downtime deploy với symlink: giữ bản cũ chạy đến khi bản mới ready.

Yêu cầu trước khi bắt đầu

  • Một VPS Linux (bài dùng Ubuntu 24.04 LTS, nhưng Debian 12 hoặc AlmaLinux 9 cũng tương tự), nếu chưa có, bạn có thể thuê VPS Linux với full root và IPv4 riêng để dễ cấu hình.
  • User sudo non-root trên VPS (bài dùng user deploy).
  • Một repository GitHub (public hoặc private).
  • Code đã chạy được trên VPS (ví dụ: Node.js, PHP, Docker).
  • Git, SSH key pair đã tạo trên máy local.

Vì sao nên dùng GitHub Actions cho CI/CD lên VPS thay vì Jenkins hay GitLab CI?

GitHub Actions miễn phí cho repo public. Với repo private, gói Free cho 2.000 phút/tháng, đủ cho vài chục lần push mỗi ngày. Action của bên thứ ba như appleboy/ssh-action giúp bạn chỉ cần viết một file YAML, không phải cài thêm agent nào trên VPS. Không cần Jenkins server riêng, không cần GitLab Runner. Pipeline chạy trên hạ tầng GitHub, VPS chỉ nhận lệnh SSH. Đơn giản, dễ bắt đầu.

Tuy nhiên, nếu VPS của bạn có băng thông trong nước hạn chế về quốc tế (như VPS Việt Nam với pool quốc tế dùng chung ~4-10 Mbps), việc pull image Docker lớn hoặc tải dependency từ registry nước ngoài qua runner của GitHub rồi upload lên VPS có thể chậm. Giải pháp: dùng self-hosted runner trên chính VPS, hoặc build Docker image trên VPS thay vì download. Bài này sẽ chỉ cả hai cách.

Bước 1 - Chuẩn bị SSH key và GitHub Secrets

Bạn cần một cặp SSH key riêng cho GitHub Actions. Không dùng key cá nhân của bạn. Key này chỉ để deploy.

Tạo key ED25519 (khuyến nghị thay RSA):

ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "github-actions-deploy"

Sẽ sinh ra ~/.ssh/deploy_key (private) và ~/.ssh/deploy_key.pub (public).

Thêm public key vào VPS:

ssh-copy-id -i ~/.ssh/deploy_key.pub deploy@your-vps-ip

Nếu không có ssh-copy-id, copy thủ công:

cat ~/.ssh/deploy_key.pub | ssh deploy@your-vps-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

Kiểm tra: Đăng nhập thử với key này:

ssh -i ~/.ssh/deploy_key deploy@your-vps-ip

Phải vào được VPS không hỏi password.

Thêm private key vào GitHub Secrets:

Vào repo GitHub → Settings → Secrets and variables → Actions → New repository secret. Đặt tên SSH_PRIVATE_KEY, paste nội dung file ~/.ssh/deploy_key vào (bao gồm cả dòng -----BEGIN OPENSSH PRIVATE KEY----------END-----).

Tạo thêm 2 secrets:

  • SSH_HOST: địa chỉ IP hoặc domain của VPS.
  • SSH_USER: tên user (vd: deploy).

Lưu ý bảo mật: Ghim các action của bên thứ ba vào SHA commit thay vì tag hoặc branch để tránh bị change malicious. Ví dụ: thay appleboy/[email protected] bằng appleboy/ssh-action@7e7a8f... (lấy SHA từ GitHub release).

Bước 2 - Tạo GitHub Actions Workflow

Trong repo của bạn, tạo thư mục .github/workflows/ và file deploy.yml.

Workflow cơ bản (Node.js + SSH):

name: Deploy to VPS

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build --if-present

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --production
            npm run build --if-present
            pm2 restart myapp

Giải thích:

  • on: push: branches: [ main ]: workflow chạy mỗi khi push lên branch main.
  • Bước checkout + setup Node + install + build chạy trên runner GitHub, không ảnh hưởng VPS.
  • Bước appleboy/ssh-action: SSH vào VPS, chạy lệnh git pull, cài production dependencies, build lại, restart app với PM2.

Kiểm tra: Push code lên branch main → vào GitHub repo → tab Actions → xem workflow chạy. Nếu thành công, thấy dấu tick xanh.

Bước 3 - Deploy với Docker trên VPS

Nếu app của bạn chạy Docker, workflow khác đi một chút. Build image trên runner hoặc trên VPS tùy vào nhu cầu.

Cách 1: Build image trên runner, upload lên VPS (phù hợp app nhỏ, image <500MB, băng thông VPS ổn):

      - name: Build Docker image
        run: docker build -t myapp:latest .

      - name: Save and compress image
        run: docker save myapp:latest | gzip > myapp.tar.gz

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker load < /tmp/myapp.tar.gz
            docker-compose -f /var/www/myapp/docker-compose.yml up -d --force-recreate myapp

Cách 2: Build trực tiếp trên VPS (khuyên dùng nếu VPS ở Việt Nam, băng thông quốc tế hạn chế):

      - name: Deploy via SSH (build on VPS)
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            docker compose -f docker-compose.yml build --no-cache
            docker compose up -d --force-recreate

Ở cách 2, bạn chỉ tốn thời gian copy source từ git (vài MB), còn build diễn ra trên VPS với CPU và RAM của VPS. Nếu VPS bạn có NVMe SSD và RAM đủ, build Docker sẽ nhanh hơn nhiều so với việc tải image nặng từ internet.

Kiểm tra: Sau khi workflow chạy, SSH vào VPS và kiểm tra:

docker ps | grep myapp
docker compose logs --tail=20 myapp

Bước 4 - Zero-downtime deploy với symlink

Restart app có thể gây downtime vài giây. Với app quan trọng, dùng kỹ thuật symlink: giữ bản cũ chạy đến khi bản mới ready, rồi chuyển symlink và graceful restart.

Cấu trúc thư mục trên VPS:

/var/www/myapp/
├── releases/
│   ├── 20260615-1/   # bản mới
│   └── 20260614-1/   # bản cũ
├── current -> releases/20260615-1/
└── deploy.sh

Script deploy.sh mẫu:

#!/bin/bash
DEPLOY_DIR="/var/www/myapp"
RELEASE_DIR="$DEPLOY_DIR/releases/$(date +%Y%m%d-%H%M)"
REPO_URL="[email protected]:user/repo.git"

mkdir -p $DEPLOY_DIR/releases
git clone $REPO_URL $RELEASE_DIR
cd $RELEASE_DIR

# Build
npm ci && npm run build

# Switch symlink
ln -sfn $RELEASE_DIR $DEPLOY_DIR/current

# Graceful restart (PM2)
pm2 restart ecosystem.config.js || pm2 start ecosystem.config.js

# Clean up old releases (keep last 5)
cd $DEPLOY_DIR/releases && ls -t | tail -n +6 | xargs rm -rf

Workflow gọi script này:

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: bash /var/www/myapp/deploy.sh

Trong thời gian build ở release mới, bản cũ ở symlink current vẫn chạy. Chỉ đến khi symlink được chuyển, app mới dùng code mới. Nếu build lỗi, release cũ vẫn còn nguyên.

Kiểm tra:

ls -la /var/www/myapp/current
# Output: lrwxrwxrwx ... current -> releases/20260615-1/

Bước 5 - Bảo mật workflow và giám sát

Các rủi ro thường gặp: SSH key bị lộ, secret bị đọc từ log, workflow bị trigger từ branch không mong muốn.

Giới hạn branch trigger:

on:
  push:
    branches: [ main, 'release/*' ]

Không log secret: GitHub tự động mask secret trong log. Nhưng đừng echo ${{ secrets.SSH_PRIVATE_KEY }} trong script.

Dùng deploy key thay vì personal key: Deploy key chỉ có quyền đọc/ghi repo đó, không ảnh hưởng repo khác. Tạo deploy key trong Settings → Deploy keys, paste public key của deploy_key, tick "Allow write access" nếu cần git push (vd: bump version).

Giám sát deploy fail: GitHub Actions có built-in notification qua email nếu workflow fail. Bạn cũng có thể thêm step gửi Telegram:

      - name: Notify on failure
        if: failure()
        uses: appleboy/[email protected]
        with:
          to: ${{ secrets.TELEGRAM_CHAT_ID }}
          token: ${{ secrets.TELEGRAM_TOKEN }}
          message: "Deploy thất bại! Xem log: https://github.com/user/repo/actions"

Xử lý lỗi thường gặp

1. Permission denied (publickey): Kiểm tra private key trong GitHub Secrets có đúng không (cả dòng BEGIN và END). Kiểm tra public key đã được thêm vào ~/.ssh/authorized_keys của user deploy. Thử SSH thủ công với key đó.

2. Host key verification failed: Khi lần đầu SSH từ runner, GitHub chưa biết host VPS. Thêm host: ${{ secrets.SSH_HOST }} và dùng action appleboy/ssh-action có option fingerprint, hoặc thêm strictHostKeyChecking=yes kèm known_hosts. Cách nhanh: set host: ${{ secrets.SSH_HOST }} và thêm secret SSH_FINGERPRINT (lấy từ ssh-keyscan -H your-vps-ip). Cách tạm nhưng dễ: dùng script: | ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts trước lệnh pull.

3. Out of memory khi build Docker trên VPS nhỏ: Kiểm tra RAM VPS. VPS 2GB RAM có thể build Docker nhưng dễ OOM nếu app heavy. Tăng swap: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile. Hoặc build trên runner và chỉ load image lên VPS.

4. Workflow chạy nhưng app không cập nhật: Kiểm tra git pull có xung đột không (local changes). Nếu có, thêm git stash trước pull. Kiểm tra symlink có được cập nhật không. Kiểm tra pm2 status hoặc systemctl status xem service có restart thật không.

Câu hỏi thường gặp

Tôi có cần mở port SSH cho GitHub Actions không?

Port SSH (mặc định 22) phải mở trên firewall của VPS. Nếu dùng ufw: ufw allow 22/tcp (hoặc port bạn đã đổi). GitHub Actions kết nối ra internet, không cần whitelist IP vì runner dùng IP động của GitHub. Bạn nên giới hạn source IP nếu có thể, nhưng với SSH key mạnh (ED25519) và không dùng password thì rủi ro thấp.

Dùng self-hosted runner có tốt hơn không?

Có, nếu VPS của bạn có băng thông quốc tế chậm và build Docker nặng. Self-hosted runner cài trên VPS, pipeline chạy ngay trên máy đó, không tốn thời gian download/upload. Cài đặt: mkdir actions-runner && cd actions-runner && curl -o actions-runner-linux-x64.tar.gz -L https://github.com/actions/runner/releases/download/v2.316.0/actions-runner-linux-x64.tar.gz && tar xzf actions-runner-linux-x64.tar.gz, rồi chạy ./config.sh --url https://github.com/user/repo --token YOUR_TOKEN. Sau đó dùng runs-on: self-hosted trong workflow.

Tôi có thể deploy nhiều service cùng lúc không?

Có, dùng matrix strategy trong GitHub Actions. Ví dụ: service A, B, C mỗi cái một thư mục, bạn định nghĩa matrix service: [a, b, c] rồi dùng ${{ matrix.service }} trong script SSH.

Làm sao để rollback nếu deploy lỗi?

Với zero-downtime deploy ở Bước 4, bạn chỉ cần SSH vào và chạy ln -sfn releases/RELEASE_CU current && pm2 restart. Hoặc tạo workflow rollback riêng: trigger manual từ GitHub UI, chạy script đổi symlink về release cũ.

GitHub Actions có tốn phí không?

Miễn phí không giới hạn phút cho repository public. Với repository private, gói Free được 2.000 phút/tháng (khoảng 33 giờ). Nếu bạn deploy >20-30 lần/ngày và mỗi lần chạy 5 phút, có thể hết free quota. Lúc đó nâng lên gói Team (3.000 phút) hoặc dùng self-hosted runner.

Bài viết liên quan

Lưu ý: Bài viết mang tính tham khảo, tổng hợp kiến thức chung. Mỗi hệ thống, hạ tầng và nhu cầu có đặc thù riêng, nên kiểm thử trong môi trường an toàn và tham vấn kỹ sư trước khi triển khai thực tế.