GitHub Actions CI/CD: Tự động deploy Node.js lên VPS
Tạo GitHub Actions CI/CD để build, test và tự động deploy Node.js lên VPS qua SSH an toàn, có secrets, healthcheck, concurrency và rollback.
Bài viết bởi Phạm Minh Thiện
Front-end Developer, trực tiếp thực hành Next.js, NestJS, Docker, Nginx và VPS Ubuntu.
Bài viết đã được kiểm tra lại ngày 21/7/2026.
Mục lục bài viết

GitHub Actions deploy VPS giúp thay quy trình SSH thủ công bằng pipeline có thể lặp lại: checkout code, cài dependency, lint, test, build, deploy và healthcheck. Nhưng workflow chỉ “xanh” chưa đảm bảo ứng dụng production hoạt động nếu bạn không kiểm tra host key, quyền user deploy, concurrency, migration và rollback.
Bài viết xây dựng CI trước rồi mới thêm CD qua SSH. Private key được lưu trong GitHub Secrets, server dùng deploy script cố định và pipeline chỉ thành công khi endpoint health trả về kết quả hợp lệ.
Tóm tắt nhanh: Tách CI và deploy; dùng key riêng cho automation; xác minh host key; giới hạn quyền user deploy; khóa concurrency; healthcheck sau deploy và chuẩn bị rollback trước khi bật tự động hóa production.

Demo pipeline: deploy chỉ thành công sau healthcheck
Một workflow nên thất bại khi website không phản hồi, thay vì kết thúc ngay sau lệnh restart. Đoạn kiểm tra dưới đây cho phép curl --fail trả exit code khác 0 và dừng job.

npm ci
npm run lint
npm test
npm run build
ssh deploy@server sudo /usr/local/bin/deploy-app
curl --fail --retry 5 --retry-delay 3 https://api.example.com/health
Lưu ý: Output trong ảnh và ví dụ là demo lab đã ẩn thông tin nhạy cảm. Khi chụp terminal thật, hãy che IP quản trị, username nội bộ, token, private key, mật khẩu và chuỗi kết nối database.
CI và CD là gì?
Continuous Integration
Mỗi thay đổi được tự động:
- Cài dependency.
- Lint.
- Type check.
- Test.
- Build.
Continuous Delivery/Deployment
Sau khi kiểm tra thành công, artifact hoặc image được đưa tới staging/production.
Push/Pull Request
↓
Install
↓
Lint + Test
↓
Build
↓
Deploy
↓
Healthcheck
Bắt đầu bằng CI trước
Đừng tự động deploy ngay từ workflow đầu tiên. Hãy làm lint, test và build ổn định trên pull request trước.
Workflow CI
Tạo:
.github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --runInBand
- name: Build
run: npm run build
Nếu project chưa có script, hãy thêm script đúng thay vì dùng || true để che lỗi.
Action bên thứ ba
Chỉ dùng action từ nguồn đáng tin cậy. Đọc quyền action yêu cầu. Với hệ thống quan trọng, cân nhắc pin theo commit SHA và dùng công cụ cập nhật dependency.
Mô hình deploy qua SSH
GitHub runner
↓ SSH
VPS chạy deploy script
Tạo key riêng cho deployment, không dùng private key cá nhân.
Tạo deployment key
ssh-keygen \
-t ed25519 \
-C "github-actions-deploy" \
-f github-actions-deploy
Tạo:
github-actions-deploy
github-actions-deploy.pub
- Public key thêm vào VPS.
- Private key lưu GitHub Secret.
- User deploy có quyền tối thiểu.
Thêm public key lên VPS
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
GitHub Secrets
Trong repository:
Settings
→ Secrets and variables
→ Actions
Tạo:
VPS_HOST
VPS_PORT
VPS_USER
VPS_SSH_KEY
VPS_HOST_KEY
Không in secret ra log.
Xác minh host key
Không chấp nhận host key mù quáng.
Trên VPS:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Hoặc lấy public host key qua console tin cậy:
cat /etc/ssh/ssh_host_ed25519_key.pub
Lưu known_hosts đã xác minh vào secret.
Deploy script trên VPS
Tạo:
/usr/local/bin/deploy-deployeasy
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="/var/www/deployeasy-api"
BRANCH="main"
cd "$APP_DIR"
git fetch origin "$BRANCH"
git checkout "$BRANCH"
git reset --hard "origin/$BRANCH"
npm ci
npm run build
pm2 restart ecosystem.config.cjs --update-env
curl --fail --silent --show-error \
http://127.0.0.1:3001/health
Permission:
sudo chmod 750 /usr/local/bin/deploy-deployeasy
Production directory không nên chứa sửa đổi thủ công vì git reset --hard sẽ xóa chúng.
Workflow deploy
.github/workflows/deploy.yml
name: Deploy production
on:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://api.deployeasy.vn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install
run: npm ci
- name: Test
run: npm test -- --runInBand
- name: Build
run: npm run build
- name: Prepare SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST_KEY: ${{ secrets.VPS_HOST_KEY }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$VPS_HOST_KEY" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Deploy
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_PORT: ${{ secrets.VPS_PORT }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
ssh \
-i ~/.ssh/deploy_key \
-p "$VPS_PORT" \
"$VPS_USER@$VPS_HOST" \
"/usr/local/bin/deploy-deployeasy"
- name: Verify
run: |
curl \
--fail \
--retry 5 \
--retry-delay 5 \
https://api.deployeasy.vn/health
GitHub Environment
Environment production có thể dùng:
- Environment secrets.
- Required reviewers.
- Deployment branches.
- Protection rules.
- Lịch sử deployment.
Khả năng áp dụng phụ thuộc loại repository và gói GitHub hiện tại.
Concurrency
concurrency:
group: production
cancel-in-progress: false
Ngăn hai deployment chạy chồng nhau.
Hủy deploy đang chạy có thể để server ở trạng thái dở dang, nên không bật cancel-in-progress: true thiếu đánh giá.
Deploy artifact thay vì build trên VPS
Quy trình:
Runner build
↓
Artifact
↓ SCP
VPS giải nén
↓
PM2 restart
Đóng gói:
- name: Create artifact
run: |
tar -czf release.tar.gz \
dist \
package.json \
package-lock.json \
ecosystem.config.cjs
Upload:
- name: Upload artifact
run: |
scp \
-i ~/.ssh/deploy_key \
-P "$VPS_PORT" \
release.tar.gz \
"$VPS_USER@$VPS_HOST:/tmp/release.tar.gz"
Ưu điểm:
- VPS không cần nhiều RAM build.
- Artifact đã được CI kiểm tra.
- Dễ version và rollback.
Deploy Docker image
CI build image
↓
Push GHCR
↓
VPS pull image
↓
Migration
↓
docker compose up -d
Tag bằng commit SHA, không chỉ latest.
Ví dụ:
env:
IMAGE: ghcr.io/${{ github.repository }}/api
- name: Build
run: |
docker build \
-t "$IMAGE:${{ github.sha }}" \
.
GHCR permissions
permissions:
contents: read
packages: write
Login:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Kiểm tra phiên bản action và tài liệu hiện hành trước production.
Migration
Thứ tự an toàn hơn:
Backup
Deploy code tương thích
Run migration
Switch/restart
Healthcheck
Migration phá backward compatibility có thể làm rollback code thất bại.
Chiến lược expand/contract:
- Thêm cấu trúc mới.
- Deploy code hỗ trợ cả cũ và mới.
- Chuyển dữ liệu.
- Ngừng dùng cấu trúc cũ.
- Xóa ở release sau.
Rollback
Artifact:
releases/
├── git-a1b2c3d
├── git-d4e5f6g
└── current -> git-d4e5f6g
Docker:
API_IMAGE_TAG=git-a1b2c3d
docker compose pull
docker compose up -d
Không rollback database mù quáng.
Healthcheck sau deploy
Nội bộ:
curl -f http://127.0.0.1:3001/health
Public:
curl -f https://api.deployeasy.vn/health
Có thể kiểm tra version endpoint và một request read-only.
Least privilege
Deploy user không nên có sudo ALL.
Chỉ cấp quyền cần:
- App directory.
- PM2 của chính user.
- Script giới hạn.
- Docker nếu cần, nhưng Docker group gần tương đương root.
Bảo vệ secrets
- Không echo.
- Tách staging/production.
- Rotate deploy key.
- Xóa key không còn dùng.
- Không cho workflow từ fork truy cập secret.
- Review action bên thứ ba.
Lỗi thường gặp
Permission denied (publickey)
- Sai user.
- Sai private key.
- Public key chưa thêm.
- Permission sai.
- Secret mất newline.
Host key verification failed
Known_hosts thiếu hoặc host key thay đổi. Xác minh fingerprint trước khi cập nhật.
Build local pass, CI fail
- Node version khác.
- Tên file khác chữ hoa/thường.
- Missing env.
- Lock file không đồng bộ.
- Test phụ thuộc local.
Workflow xanh nhưng app lỗi
Cần public healthcheck và log server.
Checklist
[ ] PR chạy lint/test/build
[ ] Deploy chỉ từ branch được phép
[ ] Environment production
[ ] Concurrency
[ ] Deploy key riêng
[ ] Host key đã xác minh
[ ] Secret không trong YAML
[ ] Deploy user giới hạn quyền
[ ] Artifact/image có version
[ ] Backup trước migration
[ ] Healthcheck nội bộ/public
[ ] Rollback đã thử
[ ] Log không lộ secret
Câu hỏi thường gặp
Có nên deploy mỗi push vào main?
Chỉ khi branch protection, test và rollback đủ tin cậy. Production quan trọng có thể cần approval.
Có cần SSH vào VPS không?
Không bắt buộc. Có thể deploy qua registry hoặc API cloud.
Có nên chạy self-hosted runner trên VPS production?
Cần đánh giá bảo mật kỹ vì workflow có thể thực thi code trên server.
Bài viết liên quan
- SSH Key đăng nhập VPS an toàn
- Sửa lỗi SSH publickey và host key
- Deploy Node.js bằng PM2 và Nginx
- Monitoring production
Kết luận
Pipeline tốt không chỉ tự động chạy lệnh. Nó phải có quyền tối thiểu, environment được bảo vệ, concurrency, healthcheck và rollback.
Nguồn tham khảo
Đọc tiếp
Bài viết liên quan
Deploy Docker lên VPS bằng GitHub Actions: CI/CD tự động 2026
Hướng dẫn build Docker image trên GitHub Actions, đẩy lên GHCR và tự động deploy lên VPS Ubuntu, tránh lỗi Killed 137 trên server RAM thấp.
Đọc bài →Deploy Node.js lên VPS bằng PM2 và Nginx từ A–Z
Hướng dẫn deploy Node.js lên VPS Ubuntu bằng PM2 và Nginx: build, biến môi trường, reverse proxy, HTTPS, log, healthcheck và rollback.
Đọc bài →Cách trỏ tên miền về VPS bằng DNS, Cloudflare và Nginx
Hướng dẫn trỏ tên miền về VPS bằng A, AAAA, CNAME; kiểm tra DNS propagation, cấu hình Cloudflare, Nginx và xử lý lỗi www.
Đọc bài →