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.
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 26/7/2026.
Mục lục bài viết
Bạn đang deploy ứng dụng bằng cách SSH vào VPS, chạy git pull, sau đó dùng:
docker compose up -d --build
Cách này đơn giản nhưng dễ gặp vấn đề khi VPS chỉ có 1 GB hoặc 2 GB RAM. Quá trình build ứng dụng Node.js, Next.js, NestJS hoặc React có thể dùng nhiều bộ nhớ, khiến tiến trình bị hệ điều hành dừng và xuất hiện lỗi:
Killed
exit code 137
Giải pháp tốt hơn là build Docker image trên GitHub Actions, lưu image tại GitHub Container Registry (GHCR), sau đó để VPS chỉ thực hiện ba việc:
- Kéo image mới về.
- Tạo lại container.
- Kiểm tra trạng thái ứng dụng.
Trong bài này, chúng ta sẽ xây dựng một pipeline CI/CD hoàn chỉnh theo luồng:
Push code lên main
↓
GitHub Actions build Docker image
↓
Đẩy image lên GHCR
↓
SSH vào VPS
↓
Docker Compose pull image theo commit SHA
↓
Khởi động phiên bản mới
Cách triển khai này phù hợp với website, API và ứng dụng Docker chạy trên một VPS Ubuntu.
Tại sao không nên build Docker trực tiếp trên VPS cấu hình thấp?
Khi chạy docker compose up -d --build, VPS phải đồng thời thực hiện nhiều công việc:
- Tải dependency.
- Chạy
npm installhoặcnpm ci. - Biên dịch TypeScript.
- Build Next.js, NestJS hoặc frontend.
- Tạo các layer của Docker image.
- Chạy database và các container đang hoạt động.
Với VPS 1 GB RAM, quá trình build có thể cạnh tranh bộ nhớ với PostgreSQL, Redis, Nginx và ứng dụng đang chạy.
Lỗi exit code 137 là gì?
Mã thoát 137 thường có nghĩa là tiến trình nhận tín hiệu SIGKILL. Trong môi trường VPS, nguyên nhân phổ biến là hệ thống thiếu RAM và Linux OOM Killer buộc phải dừng tiến trình.
Bạn có thể kiểm tra nhanh bằng:
free -h
dmesg -T | grep -Ei "killed process|out of memory|oom"
Thêm swap có thể giảm rủi ro, nhưng swap không biến VPS yếu thành máy build mạnh. Cách ổn định hơn là chuyển công việc build sang GitHub Actions.
Kiến trúc CI/CD trong bài viết
Pipeline được chia thành hai giai đoạn.
Giai đoạn 1: Continuous Integration
GitHub Actions sẽ:
- Checkout source code.
- Khởi tạo Docker Buildx.
- Đăng nhập GHCR bằng
GITHUB_TOKEN. - Build Docker image.
- Sử dụng cache để tăng tốc lần build sau.
- Gắn tag theo Git commit SHA.
- Push image lên
ghcr.io.
Giai đoạn 2: Continuous Deployment
Sau khi build thành công, GitHub Actions sẽ:
- Kết nối VPS bằng SSH key.
- Sao chép file
compose.prod.ymllên server. - Đăng nhập GHCR trên VPS.
- Pull đúng image vừa được build.
- Khởi động container bằng Docker Compose.
- Dọn image cũ không còn được sử dụng.
VPS không cần chứa toàn bộ source code và không cần chạy npm install hay npm run build.
Điều kiện cần chuẩn bị
Bạn cần có:
- Một repository trên GitHub.
- Dự án đã có
Dockerfile. - Một VPS Ubuntu có thể SSH.
- Docker Engine và Docker Compose plugin trên VPS.
- Tên miền là tùy chọn, nhưng nên có nếu chạy production.
- Một endpoint health check, ví dụ
/health, là tùy chọn nhưng nên dùng.
Kiểm tra Docker trên VPS:
docker --version
docker compose version
Nếu chưa cài Docker, nên cài từ repository chính thức của Docker thay vì dùng các package cũ hoặc không chính thức.
Cấu trúc thư mục đề xuất
my-app/
├── .github/
│ └── workflows/
│ └── deploy-production.yml
├── src/
├── Dockerfile
├── .dockerignore
├── compose.prod.yml
├── package.json
└── package-lock.json
Trên VPS, chúng ta chỉ cần:
/opt/my-app/
├── compose.prod.yml
├── .deploy.env
└── .env.production
Trong đó:
compose.prod.yml: mô tả container production..deploy.env: lưu tên image và commit SHA đang chạy..env.production: lưu biến môi trường bí mật của ứng dụng.
Bước 1: Tạo Dockerfile production
Ví dụ dưới đây phù hợp với một ứng dụng Node.js có lệnh build và lệnh start:prod.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/package*.json ./
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["npm", "run", "start:prod"]
Bạn cần thay đổi thư mục output và câu lệnh chạy theo framework:
- NestJS thường build ra
dist. - Express TypeScript có thể build ra
disthoặcbuild. - Next.js nên dùng chế độ
output: "standalone"và Dockerfile riêng. - React hoặc Astro dạng static nên build ra file tĩnh và phục vụ bằng Nginx.
Tạo .dockerignore
node_modules
dist
build
.next
.git
.github
.env
.env.*
npm-debug.log*
Dockerfile*
compose*.yml
README.md
Không copy file .env.production vào Docker image. Secret production nên được lưu trực tiếp trên VPS hoặc trong một secret manager.
Bước 2: Tạo file Docker Compose cho production
Tạo file compose.prod.yml:
services:
app:
image: ${IMAGE_NAME}:${IMAGE_TAG}
restart: unless-stopped
env_file:
- .env.production
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test:
[
"CMD-SHELL",
"wget -qO- http://localhost:3000/health > /dev/null || exit 1"
]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Vì sao chỉ bind vào 127.0.0.1?
Cấu hình:
ports:
- "127.0.0.1:3000:3000"
giúp cổng 3000 chỉ được truy cập từ chính VPS. Người dùng bên ngoài sẽ đi qua Nginx tại cổng 80 hoặc 443.
Nếu dùng:
ports:
- "3000:3000"
Docker có thể public cổng ứng dụng ra toàn bộ internet. Với production, bạn chỉ nên mở những cổng thật sự cần thiết.
Docker có cơ chế xử lý firewall riêng. Không nên cho rằng bật UFW là mọi cổng container đã tự động được chặn.
Nếu ứng dụng chưa có /health
Bạn có thể:
- Tạo endpoint
/health. - Đổi URL health check thành một đường dẫn luôn trả về HTTP 200.
- Tạm thời bỏ phần
healthcheckđể kiểm tra pipeline trước.
Bước 3: Chuẩn bị user deploy trên VPS
Đăng nhập VPS bằng user có quyền sudo:
ssh root@YOUR_VPS_IP
Tạo user riêng:
adduser deploy
usermod -aG docker deploy
Tạo thư mục ứng dụng:
mkdir -p /opt/my-app
chown -R deploy:deploy /opt/my-app
Kiểm tra user deploy có thể chạy Docker:
su - deploy
docker ps
Thành viên của group
dockercó quyền rất lớn trên server, gần tương đương quyền root. Chỉ thêm user triển khai đáng tin cậy vào group này và bảo vệ SSH key cẩn thận.
Bước 4: Tạo SSH key dành riêng cho GitHub Actions
Tạo key trên máy cá nhân:
ssh-keygen \
-t ed25519 \
-C "github-actions-production-deploy" \
-f github-actions-deploy
Lệnh sẽ tạo:
github-actions-deploy
github-actions-deploy.pub
- File không có đuôi là private key.
- File
.publà public key.
Thêm public key vào VPS:
ssh-copy-id -i github-actions-deploy.pub deploy@YOUR_VPS_IP
Hoặc copy nội dung public key vào:
/home/deploy/.ssh/authorized_keys
Thiết lập quyền:
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
Kiểm tra kết nối:
ssh -i github-actions-deploy deploy@YOUR_VPS_IP
Không upload private key lên repository và không gửi key qua chat công khai.
Bước 5: Lưu GitHub Actions Secrets
Vào repository GitHub:
Settings
→ Secrets and variables
→ Actions
→ New repository secret
Tạo các secret sau:
| Secret | Nội dung |
|---|---|
VPS_HOST |
IP hoặc hostname của VPS |
VPS_USER |
deploy |
VPS_SSH_PRIVATE_KEY |
Nội dung file github-actions-deploy |
VPS_KNOWN_HOSTS |
Host key đã xác minh của VPS |
GHCR_USERNAME |
GitHub username hoặc tên organization |
GHCR_READ_TOKEN |
Token chỉ có quyền read:packages |
Lấy VPS_KNOWN_HOSTS
Trên máy cá nhân, chạy:
ssh-keyscan -H YOUR_VPS_IP
Trước khi lưu kết quả, nên xác minh fingerprint với fingerprint thật trên VPS:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Không nên dùng ssh-keyscan trực tiếp trong mỗi lần workflow chạy rồi tin kết quả ngay lập tức, vì cách đó không xác minh danh tính server một cách chặt chẽ.
Tại sao cần GHCR_READ_TOKEN?
Trong GitHub Actions, workflow có thể dùng GITHUB_TOKEN để push image của repository lên GHCR.
Tuy nhiên, VPS nằm bên ngoài runner và cần quyền riêng để pull private image. Hãy tạo một Personal Access Token classic chỉ có scope:
read:packages
Không cấp repo, write:packages hoặc delete:packages nếu VPS không cần các quyền đó.
Nếu image GHCR được đặt ở chế độ public, VPS có thể pull mà không cần đăng nhập. Tuy nhiên, nhiều dự án production sử dụng private image.
Bước 6: Tạo file biến môi trường trên VPS
Đăng nhập bằng user deploy:
ssh deploy@YOUR_VPS_IP
cd /opt/my-app
Tạo file:
nano .env.production
Ví dụ:
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://app_user:strong_password@db:5432/app_db
JWT_SECRET=replace_with_a_long_random_secret
Thiết lập quyền:
chmod 600 .env.production
Không commit file này lên GitHub.
Nếu database chạy ngoài file Compose hiện tại, hãy điều chỉnh hostname trong DATABASE_URL. Nếu PostgreSQL chạy cùng Compose, bạn cần thêm service database và volume tương ứng.
Bước 7: Tạo GitHub Actions workflow
Tạo file:
.github/workflows/deploy-production.yml
Nội dung:
name: Build and deploy production
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: production-deploy
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build:
name: Build and push image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_name: ${{ steps.image.outputs.name }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
- name: Create lowercase image name
id: image
shell: bash
run: |
IMAGE_NAME="${REGISTRY}/${GITHUB_REPOSITORY,,}"
echo "name=${IMAGE_NAME}" >> "${GITHUB_OUTPUT}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ steps.image.outputs.name }}:${{ github.sha }}
${{ steps.image.outputs.name }}:latest
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to VPS
needs:
- build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- name: Checkout deployment files
uses: actions/checkout@v6
- name: Configure SSH
shell: bash
env:
SSH_PRIVATE_KEY: ${{ secrets.VPS_SSH_PRIVATE_KEY }}
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
install -m 700 -d "${HOME}/.ssh"
printf '%s\n' "${SSH_PRIVATE_KEY}" \
> "${HOME}/.ssh/deploy_key"
printf '%s\n' "${VPS_KNOWN_HOSTS}" \
> "${HOME}/.ssh/known_hosts"
chmod 600 "${HOME}/.ssh/deploy_key"
chmod 600 "${HOME}/.ssh/known_hosts"
- name: Upload Docker Compose file
shell: bash
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
scp \
-i "${HOME}/.ssh/deploy_key" \
compose.prod.yml \
"${VPS_USER}@${VPS_HOST}:/opt/my-app/compose.prod.yml"
- name: Log in to GHCR on VPS
shell: bash
env:
GHCR_READ_TOKEN: ${{ secrets.GHCR_READ_TOKEN }}
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
printf '%s' "${GHCR_READ_TOKEN}" |
ssh \
-i "${HOME}/.ssh/deploy_key" \
"${VPS_USER}@${VPS_HOST}" \
"docker login ghcr.io \
-u '${GHCR_USERNAME}' \
--password-stdin"
- name: Pull and start new version
shell: bash
env:
IMAGE_NAME: ${{ needs.build.outputs.image_name }}
IMAGE_TAG: ${{ github.sha }}
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
ssh \
-i "${HOME}/.ssh/deploy_key" \
"${VPS_USER}@${VPS_HOST}" \
"set -e
cd /opt/my-app
printf 'IMAGE_NAME=%s\nIMAGE_TAG=%s\n' \
'${IMAGE_NAME}' \
'${IMAGE_TAG}' \
> .deploy.env
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
pull
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
up -d --remove-orphans
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
ps
docker image prune -f"
Pipeline này làm gì?
Khi có commit mới được push lên nhánh main:
- GitHub Actions build image trên runner.
- Image được gắn hai tag:
- Commit SHA, ví dụ
a31f2c.... latest.
- Commit SHA, ví dụ
- Image được push lên GHCR.
- Job deploy chỉ chạy khi job build thành công.
- File Compose mới nhất được chuyển lên VPS.
- VPS pull image theo commit SHA.
- Docker Compose khởi động phiên bản vừa build.
Tại sao deploy theo commit SHA thay vì chỉ dùng latest?
Tag latest tiện để xem phiên bản gần nhất nhưng không phải định danh bất biến.
Ví dụ:
ghcr.io/username/my-app:latest
Hôm nay và ngày mai có thể trỏ tới hai image khác nhau.
Ngược lại:
ghcr.io/username/my-app:a31f2c4...
gắn với một commit cụ thể. Điều này giúp:
- Biết chính xác code nào đang chạy.
- Rollback về phiên bản cũ.
- So sánh log với commit.
- Tránh deploy nhầm do cache hoặc tag bị ghi đè.
Trong workflow trên, latest vẫn được push để tiện theo dõi, nhưng production chạy bằng ${{ github.sha }}.
Bước 8: Cấu hình Nginx reverse proxy
Ví dụ domain:
app.example.com
Tạo file:
sudo nano /etc/nginx/sites-available/my-app
Nội dung:
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
Kích hoạt cấu hình:
sudo ln -s /etc/nginx/sites-available/my-app \
/etc/nginx/sites-enabled/my-app
sudo nginx -t
sudo systemctl reload nginx
Sau khi DNS trỏ đúng về VPS, bạn có thể cài SSL bằng Certbot.
Bước 9: Chạy pipeline lần đầu
Commit các file:
git add Dockerfile .dockerignore compose.prod.yml
git add .github/workflows/deploy-production.yml
git commit -m "ci: deploy Docker image to VPS with GitHub Actions"
git push origin main
Mở repository trên GitHub:
Actions
→ Build and deploy production
Theo dõi hai job:
Build and push image
Deploy to VPS
Sau khi workflow thành công, kiểm tra trên VPS:
cd /opt/my-app
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
ps
Xem log:
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
logs -f --tail=200 app
Kiểm tra health check:
docker inspect \
--format='{{json .State.Health}}' \
"$(docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
ps -q app)"
Cách rollback về phiên bản trước
Tìm commit SHA của phiên bản ổn định trên GitHub, ví dụ:
9f3d2b8162e8...
Trên VPS:
cd /opt/my-app
nano .deploy.env
Đổi:
IMAGE_TAG=9f3d2b8162e8...
Sau đó chạy:
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
pull
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
up -d
Vì mỗi lần build đều có tag theo commit SHA, bạn không cần build lại source code để rollback.
Lưu ý về database migration
Rollback container không tự động rollback database.
Nếu bản deploy có migration thay đổi schema, bạn cần:
- Thiết kế migration tương thích ngược.
- Backup database trước thay đổi lớn.
- Tách migration thành bước có kiểm soát.
- Không tự động chạy migration phá hủy dữ liệu trong mọi lần container start.
Thêm bước test trước khi build
Pipeline hiện tại tập trung vào build và deploy. Với dự án thực tế, nên thêm job kiểm tra trước:
jobs:
test:
name: Test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout source code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
Sau đó sửa job build:
build:
needs:
- test
Luồng mới:
Test → Build image → Push GHCR → Deploy VPS
Nếu test thất bại, image production sẽ không được build và VPS không bị thay đổi.
Bật phê duyệt trước khi deploy production
GitHub Environments có thể được dùng để quản lý môi trường production.
Workflow đã có:
environment: production
Bạn có thể tạo environment tên production trong repository settings và cấu hình protection rule phù hợp với gói GitHub đang sử dụng.
Khi đó, quy trình có thể trở thành:
Build thành công
→ Chờ phê duyệt
→ Deploy production
Cách này hữu ích khi:
- Team có nhiều developer.
- Không muốn mọi commit vào
maintự động lên production. - Cần kiểm tra staging trước.
- Muốn giới hạn người có quyền deploy.
Bảo mật GitHub Actions và VPS
1. Chỉ cấp quyền tối thiểu cho workflow
Job build chỉ cần:
permissions:
contents: read
packages: write
Job deploy chỉ cần:
permissions:
contents: read
Không nên bật write-all nếu không thật sự cần.
2. Dùng secret thay vì ghi trực tiếp vào YAML
Không viết:
password: my-secret-token
Hãy dùng:
password: ${{ secrets.GITHUB_TOKEN }}
hoặc repository secret tương ứng.
3. Không dùng root để deploy
Dùng user riêng như:
deploy
User này chỉ nên có các quyền phục vụ triển khai.
4. Xác minh SSH host key
Lưu host key đã được xác minh trong VPS_KNOWN_HOSTS. Không vô hiệu hóa kiểm tra bằng:
StrictHostKeyChecking=no
trong production nếu không có lý do đặc biệt.
5. Pin action theo commit SHA khi cần mức bảo mật cao
Ví dụ dễ đọc thường dùng:
uses: docker/build-push-action@v7
Với pipeline production quan trọng, GitHub khuyến nghị pin action bên thứ ba vào một commit SHA đầy đủ để tránh tag bị thay đổi ngoài ý muốn.
Bạn có thể dùng Dependabot để cập nhật action có kiểm soát.
6. Không đưa secret vào Docker build argument
Không truyền secret production bằng:
ARG JWT_SECRET
hoặc ghi trực tiếp vào image. Docker image có thể được tải về, kiểm tra history hoặc chia sẻ ngoài dự kiến.
7. Giới hạn SSH
Nên:
- Tắt đăng nhập root qua SSH sau khi đã kiểm tra user sudo.
- Tắt password login nếu toàn bộ tài khoản đã dùng SSH key.
- Chỉ mở cổng cần thiết.
- Theo dõi log đăng nhập.
- Thay key ngay khi nghi ngờ bị lộ.
Tối ưu tốc độ build Docker trên GitHub Actions
Workflow sử dụng:
cache-from: type=gha
cache-to: type=gha,mode=max
BuildKit sẽ cố gắng tái sử dụng các layer không thay đổi.
Để cache hiệu quả, Dockerfile nên copy file dependency trước source code:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Không nên:
COPY . .
RUN npm ci
RUN npm run build
Với cách thứ hai, chỉ cần một file source thay đổi cũng có thể làm mất cache của bước cài dependency.
Các cách giảm kích thước image
- Dùng multi-stage build.
- Chỉ giữ dependency production trong image cuối.
- Dùng
.dockerignore. - Không copy
.git, test report và file local. - Dùng base image phù hợp.
- Không cài package hệ điều hành không cần thiết.
- Kiểm tra image bằng:
docker image ls
docker history YOUR_IMAGE
Pipeline này có zero downtime không?
Không hoàn toàn.
Lệnh:
docker compose up -d
thường sẽ tạo lại container khi image thay đổi. Ứng dụng có thể gián đoạn trong một khoảng thời gian ngắn, tùy thời gian container khởi động.
Với blog, website nhỏ, API nội bộ hoặc dự án cá nhân, mức gián đoạn này thường có thể chấp nhận.
Nếu cần zero downtime thực sự, bạn nên nghiên cứu:
- Blue-green deployment.
- Hai container chạy song song sau Nginx.
- Docker Swarm rolling update.
- Kubernetes Deployment.
- Load balancer và health check.
- Cơ chế drain connection.
Không nên quảng cáo pipeline Docker Compose một container là “zero downtime” nếu chưa triển khai cơ chế chuyển traffic an toàn.
Các lỗi thường gặp
Lỗi permission denied while trying to connect to the Docker daemon
Nguyên nhân: user deploy chưa có quyền chạy Docker.
Kiểm tra:
groups
Thêm user vào group:
sudo usermod -aG docker deploy
Sau đó đăng xuất và đăng nhập lại.
Lỗi denied: permission_denied khi push GHCR
Kiểm tra:
permissions:
contents: read
packages: write
Đồng thời kiểm tra package cũ có đang được tạo thủ công và chưa liên kết đúng với repository hay không.
Lỗi unauthorized khi VPS pull image
Kiểm tra:
GHCR_READ_TOKENcòn hiệu lực.- Token có scope
read:packages. - Username đúng.
- Package cho phép tài khoản đó truy cập.
- Lệnh
docker login ghcr.iothành công.
Thử trực tiếp trên VPS:
echo "$CR_PAT" |
docker login ghcr.io \
-u YOUR_GITHUB_USERNAME \
--password-stdin
Không dán token vào lịch sử shell nếu đang thao tác trên máy dùng chung.
Lỗi Host key verification failed
Nguyên nhân thường là:
VPS_KNOWN_HOSTSsai.- VPS vừa cài lại và SSH host key đã thay đổi.
- Dùng hostname trong workflow nhưng secret lại lưu key của IP, hoặc ngược lại.
Hãy xác minh fingerprint mới trước khi cập nhật secret. Không tự động bỏ qua cảnh báo host key changed.
Lỗi compose.prod.yml: no such file or directory
Kiểm tra file nằm đúng root repository:
compose.prod.yml
Nếu file nằm ở thư mục khác, sửa lệnh scp, ví dụ:
scp infrastructure/compose.prod.yml ...
Lỗi container chạy nhưng Nginx trả về 502
Kiểm tra:
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
ps
docker compose \
--env-file .deploy.env \
-f compose.prod.yml \
logs --tail=200 app
curl -I http://127.0.0.1:3000
Nguyên nhân phổ biến:
- Ứng dụng chưa chạy.
- App bind vào
127.0.0.1bên trong container thay vì0.0.0.0. - Sai port container.
- Biến môi trường thiếu.
- Database chưa kết nối được.
- Container liên tục restart.
Ứng dụng bên trong container thường cần lắng nghe tại:
0.0.0.0:3000
Workflow thành công nhưng website vẫn là phiên bản cũ
Kiểm tra tag đang chạy:
cat /opt/my-app/.deploy.env
Kiểm tra image của container:
docker inspect \
--format='{{.Config.Image}}' \
"$(docker compose \
--env-file /opt/my-app/.deploy.env \
-f /opt/my-app/compose.prod.yml \
ps -q app)"
Pipeline trong bài deploy theo commit SHA nên ít gặp tình trạng pull nhầm latest.
Checklist trước khi dùng production
- Dockerfile build thành công ở local.
-
.dockerignorekhông bỏ sót file cần thiết. -
.env.productionchỉ tồn tại trên VPS. - VPS dùng user deploy riêng.
- SSH password login đã được cân nhắc tắt.
- Private key chỉ được lưu trong GitHub Secret.
- SSH host key đã được xác minh.
- Workflow chỉ có permission tối thiểu.
- Image production có tag commit SHA.
- Container có
restart: unless-stopped. - Log Docker có giới hạn kích thước.
- Nginx proxy vào
127.0.0.1. - Domain đã có HTTPS.
- Database có backup.
- Có quy trình rollback.
- Có health check hoặc bước kiểm tra sau deploy.
Câu hỏi thường gặp
GitHub Actions có thể deploy trực tiếp lên VPS không?
Có. Workflow có thể kết nối tới VPS bằng SSH, sau đó chạy các lệnh triển khai. Bạn nên dùng SSH key riêng, host key đã xác minh và user deploy riêng thay vì dùng root password.
Nên dùng Docker Hub hay GHCR?
Cả hai đều có thể dùng. GHCR thuận tiện khi source code đã nằm trên GitHub vì workflow có thể dùng GITHUB_TOKEN để publish image gắn với repository. Docker Hub phù hợp khi team đang dùng hệ sinh thái Docker Hub hoặc muốn quản lý image tại một registry độc lập.
VPS 1 GB RAM có chạy Docker được không?
Có thể, nếu số lượng service ít và ứng dụng nhẹ. Tuy nhiên, 1 GB RAM thường không phù hợp để vừa chạy database, ứng dụng, Redis và vừa build Node.js. Build image trên GitHub Actions giúp giảm tải đáng kể cho VPS.
Có cần clone source code trên VPS không?
Không bắt buộc. Với mô hình trong bài, VPS chỉ cần file Compose, file environment và quyền pull image. Source code được build thành image trên GitHub Actions.
Có nên deploy bằng tag latest không?
Bạn có thể push latest để thuận tiện, nhưng production nên chạy bằng tag bất biến như commit SHA hoặc semantic version. Việc này giúp xác định phiên bản và rollback dễ hơn.
Docker Compose có phù hợp production không?
Docker Compose phù hợp với nhiều ứng dụng chạy trên một server đơn, đặc biệt là dự án nhỏ và vừa. Khi cần nhiều node, rolling update phức tạp hoặc khả năng tự phục hồi ở cấp cluster, bạn nên cân nhắc Docker Swarm, Kubernetes hoặc nền tảng container được quản lý.
Mỗi lần push main có tự deploy không?
Có, vì workflow sử dụng:
on:
push:
branches:
- main
Bạn có thể đổi thành deploy theo tag, release hoặc chạy thủ công bằng workflow_dispatch.
Kết luận
Deploy bằng cách SSH vào VPS rồi chạy docker compose up -d --build phù hợp để học hoặc thử nghiệm, nhưng dễ gây lỗi trên server RAM thấp.
Một quy trình ổn định hơn là:
GitHub Actions build image
→ GHCR lưu image
→ VPS pull image
→ Docker Compose chạy phiên bản theo commit SHA
Mô hình này mang lại các lợi ích rõ ràng:
- Không build ứng dụng trên VPS.
- Giảm nguy cơ lỗi
Killed 137. - Tự động deploy khi push code.
- Có cache build.
- Biết chính xác phiên bản đang chạy.
- Rollback nhanh bằng commit SHA.
- VPS không cần giữ toàn bộ source code.
Đây là bước chuyển hợp lý từ deploy thủ công sang CI/CD thực tế mà chưa cần triển khai Kubernetes.
Bạn có thể tiếp tục tối ưu bằng cách thêm test, security scan, approval production, backup database và chiến lược blue-green deployment.
Nguồn tham khảo
Đọc tiếp
Bài viết liên quan
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.
Đọc bài →Docker là gì? Image, Container, Volume và Compose dễ hiểu
Docker là gì và hoạt động ra sao? Giải thích image, container, Dockerfile, volume, network và Docker Compose bằng ví dụ Node.js thực tế.
Đọc bài →Docker Compose production: Node.js, PostgreSQL và backup
Cấu hình Docker Compose production cho Node.js và PostgreSQL với healthcheck, volume, network riêng, secret, log rotation, backup và rollback.
Đọc bài →