DeployEasy
CI/CDTrung bình

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.

· 21 phút đọc· 4.412 từ
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:

  1. Kéo image mới về.
  2. Tạo lại container.
  3. 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 install hoặc npm 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.yml lê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 dist hoặc build.
  • 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 docker có 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 .pub là 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:

  1. GitHub Actions build image trên runner.
  2. Image được gắn hai tag:
    • Commit SHA, ví dụ a31f2c....
    • latest.
  3. Image được push lên GHCR.
  4. Job deploy chỉ chạy khi job build thành công.
  5. File Compose mới nhất được chuyển lên VPS.
  6. VPS pull image theo commit SHA.
  7. 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 main tự độ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_TOKEN cò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.io thà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_HOSTS sai.
  • 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.1 bê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.
  • .dockerignore không bỏ sót file cần thiết.
  • .env.production chỉ 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