❤️ AZDIGI chính thức cập nhật hệ thống blog mới hoàn chỉnh. Tuy nhiên có thể một số bài viết bị sai lệch hình ảnh, hãy ấn nút Báo cáo bài viết ở cuối bài để AZDIGI cập nhật trong thời gian nhanh nhất. Chân thành cám ơn.

Bài 15, bạn đã học cách cập nhật ứng dụng trong Docker, rebuild image, rolling update, và quản lý version với tag. Nhưng mỗi lần update, bạn vẫn phải SSH vào VPS, chạy lệnh thủ công. Làm vài lần thì ổn, nhưng khi deploy thường xuyên thì… mệt.

CI/CD với Docker - Auto deploy với Webhook
Minh họa: CI/CD – Auto deploy với Webhook + Docker Compose

Bài này sẽ hướng dẫn bạn tự động hoá việc deploy: từ đơn giản nhất (Watchtower tự pull image mới) đến webhook (push code là VPS tự build + deploy), và nâng cao hơn với GitHub Actions. Push code xong, đi pha cà phê, quay lại thì app đã lên production rồi.

CI/CD là gì?

CI/CD là viết tắt của Continuous Integration / Continuous Deployment (hoặc Continuous Delivery). Nghe fancy nhưng ý tưởng rất đơn giản:

  • CI (Continuous Integration): Mỗi khi bạn push code, hệ thống tự động build, chạy test, đảm bảo code mới không break gì.
  • CD (Continuous Deployment): Nếu CI pass, hệ thống tự động deploy code mới lên server.

Dĩ nhiên ở trên là cách giải thích trực quan về CI/CD, còn về bản chất của nó là những quy trình tự động hoá mà bạn sẽ đặt ra những quy tắc nhất định để nó có thể tự động hành động ở bước tiếp theo. Vì vậy nếu nói về quy trình CI/CD thì nó sẽ khá rộng và mỗi dự án sẽ có những yêu cầu về quy trình này khác nhau. Nhưng trong khuôn khổ bài này, AZDIGI sẽ đề cập với một flow đơn giản.

Flow đơn giản nhất:

Push code → Tự động build → Tự động test → Tự động deploy
                    │                │               │
                    └── CI ──────────┘               │
                                                     └── CD

Tại sao CI/CD quan trọng?

Khi không có CI/CD, quy trình deploy thường là:

  1. SSH vào server
  2. Git pull code mới
  3. Build image
  4. Restart container
  5. Kiểm tra xem chạy ổn không
  6. Nếu lỗi → rollback thủ công

Làm thủ công vài bước này mỗi ngày, sớm muộn gì cũng quên một bước, deploy nhầm branch, hoặc quên restart service. CI/CD giải quyết bằng cách tự động hoá toàn bộ:

  • Giảm lỗi do con người: Máy không quên bước, không gõ nhầm lệnh.
  • Tiết kiệm thời gian: Deploy chỉ mất vài giây thay vì vài phút SSH + gõ lệnh.
  • Consistent: Mỗi lần deploy đều theo đúng quy trình, không ai “chạy tắt”.
  • Nhanh hơn: Deploy nhiều lần trong ngày thay vì gom lại deploy 1 lần/tuần.

Trong bài này, mình sẽ đi từ đơn giản đến phức tạp: Watchtower → Webhook → GitHub Actions.

Watchtower – Auto update Docker images

Watchtower auto update containers
Watchtower phát hiện image mới và tự update containers

Watchtower là cách đơn giản nhất để tự động cập nhật container. Nó hoạt động kiểu: định kỳ kiểm tra Docker Hub (hoặc registry khác), nếu có image mới → tự pull về và restart container.

Watchtower hoạt động thế nào?

Docker Hub có image mới (v2.1)
        │
        ▼
Watchtower check (mỗi 5 phút)
        │
        ▼
So sánh digest: image hiện tại ≠ image mới
        │
        ▼
Pull image mới → Stop container cũ → Start container mới
        │
        ▼
Container đã chạy version mới ✅

Watchtower chỉ cần mount Docker socket là nó tự quản lý tất cả. Không cần webhook, không cần script.

Deploy Watchtower bằng Docker Compose

Tạo file docker-compose.yml cho Watchtower:

services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=300
      - TZ=Asia/Ho_Chi_Minh

Giải thích:

  • /var/run/docker.sock: Watchtower cần truy cập Docker daemon để quản lý container.
  • WATCHTOWER_CLEANUP=true: Tự xoá image cũ sau khi update, tránh tốn disk.
  • WATCHTOWER_POLL_INTERVAL=300: Kiểm tra mỗi 300 giây (5 phút). Tuỳ nhu cầu bạn có thể tăng lên 3600 (1 giờ) hoặc hơn.
docker compose up -d

Vậy là xong! Watchtower sẽ tự chạy ngầm và update container khi có image mới.

Chỉ update container có label

Mặc định, Watchtower sẽ update tất cả container đang chạy. Đôi khi bạn không muốn vậy, ví dụ database container thì không nên tự update.

Để chỉ update những container bạn chọn, thêm biến WATCHTOWER_LABEL_ENABLE=true:

services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=300
      - WATCHTOWER_LABEL_ENABLE=true
      - TZ=Asia/Ho_Chi_Minh

# Container này SẼ được Watchtower update webapp: image: myuser/webapp:latest labels: - "com.centurylinklabs.watchtower.enable=true"

# Container này KHÔNG bị update (không có label) database: image: mysql:8.0 # Không có watchtower label → Watchtower bỏ qua

Chỉ container nào có label com.centurylinklabs.watchtower.enable=true mới được Watchtower kiểm tra và update.

Notification khi update

Watchtower hỗ trợ gửi thông báo qua nhiều kênh. Ví dụ gửi notification qua Discord:

services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=300
      - WATCHTOWER_LABEL_ENABLE=true
      - WATCHTOWER_NOTIFICATIONS=shoutrrr
      - WATCHTOWER_NOTIFICATION_URL=discord://token@webhookid
      - TZ=Asia/Ho_Chi_Minh

Watchtower sử dụng Shoutrrr để gửi notification. Ngoài Discord, bạn còn có thể dùng Telegram, Slack, email, v.v. Xem danh sách đầy đủ tại Shoutrrr Services.

Ưu và nhược điểm của Watchtower

Ưu điểm:

  • Cực kỳ đơn giản: chỉ cần 1 container là chạy.
  • Không cần viết script, không cần webhook.
  • Phù hợp cho project nhỏ, side project, hoặc staging server.

Nhược điểm:

  • Không có rollback: Nếu image mới bị lỗi, Watchtower không tự rollback về version cũ.
  • Không kiểm soát thời điểm deploy: Update xảy ra khi Watchtower poll, không phải khi bạn muốn.
  • Chỉ hoạt động với pre-built image: Nếu bạn build image từ source code trên VPS, Watchtower không giúp được.
  • Downtime: Stop container cũ trước khi start container mới → có khoảng thời gian app không chạy.

Watchtower phù hợp khi bạn dùng image từ Docker Hub hoặc registry, và chấp nhận “có gì mới thì update”. Nếu cần kiểm soát chặt hơn, đọc tiếp phần Webhook.

Webhook deploy – Tự build + deploy khi push code

Build và push Docker image
Build, tag và push image lên registry

Webhook là cách phổ biến nhất để tự động deploy khi push code. Flow như sau:

Developer push code lên GitHub/Gitea
        │
        ▼
GitHub gửi HTTP POST đến VPS (webhook URL)
        │
        ▼
Webhook server trên VPS nhận request
        │
        ▼
Chạy deploy script:
  1. git pull
  2. docker compose build
  3. docker compose up -d
        │
        ▼
App đã update! ✅

Mình sẽ dùng adnanh/webhook, một webhook server cực nhẹ, viết bằng Go, dễ config.

Chuẩn bị cấu trúc project

Giả sử project của bạn có cấu trúc như sau trên VPS:

/opt/myapp/
├── docker-compose.yml
├── Dockerfile
├── src/
│   └── ...
└── deploy/
    ├── hooks.json        # Webhook config
    └── deploy.sh         # Deploy script

Bước 1: Tạo deploy script

Tạo file deploy/deploy.sh:

#!/bin/bash
set -e

# === Config === APP_DIR="/opt/myapp" LOG_FILE="/var/log/deploy.log" COMPOSE_FILE="docker-compose.yml"

# === Functions === log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" }

# === Deploy === log "🚀 Deploy started"

cd "$APP_DIR"

# Pull code mới log "📥 Pulling latest code..." git pull origin main

# Build image mới log "🔨 Building images..." docker compose -f "$COMPOSE_FILE" build --no-cache

# Deploy log "🔄 Deploying..." docker compose -f "$COMPOSE_FILE" up -d

# Cleanup log "🧹 Cleaning up old images..." docker image prune -f

log "✅ Deploy completed!"
chmod +x deploy/deploy.sh

Bước 2: Config webhook (hooks.json)

Tạo file deploy/hooks.json:

[
  {
    "id": "deploy-myapp",
    "execute-command": "/etc/webhook/deploy.sh",
    "command-working-directory": "/opt/myapp",
    "pass-arguments-to-command": [],
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "payload-hmac-sha256",
            "secret": "your-webhook-secret-here",
            "parameter": {
              "source": "header",
              "name": "X-Hub-Signature-256"
            }
          }
        },
        {
          "match": {
            "type": "value",
            "value": "refs/heads/main",
            "parameter": {
              "source": "payload",
              "name": "ref"
            }
          }
        }
      ]
    }
  }
]

Giải thích config:

  • id: Tên webhook, URL sẽ là http://your-vps:9000/hooks/deploy-myapp.
  • execute-command: Script sẽ chạy khi webhook được trigger.
  • trigger-rule: Điều kiện để trigger. Ở đây mình kiểm tra 2 thứ:
  • HMAC signature: Đảm bảo request đến từ GitHub (không phải ai đó random gửi POST).
  • Branch: Chỉ deploy khi push vào branch main.

Bước 3: Deploy webhook server bằng Docker

Thêm webhook service vào docker-compose.yml của project:

services:
  # App chính
  webapp:
    build: .
    ports:
      - "3000:3000"
    restart: unless-stopped
# Webhook server
  webhook:
    image: almir/webhook
    container_name: webhook-server
    restart: unless-stopped
    ports:
      - "9000:9000"
    volumes:
      - ./deploy/hooks.json:/etc/webhook/hooks.json
      - ./deploy/deploy.sh:/etc/webhook/deploy.sh
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/myapp:/opt/myapp
    command: ["-verbose", "-hooks=/etc/webhook/hooks.json", "-hotreload"]

Lưu ý quan trọng:

  • Mount Docker socket để deploy script có thể chạy docker compose commands.
  • Mount app directory để script có thể git pull.
  • -hotreload cho phép thay đổi hooks.json mà không cần restart webhook server.
docker compose up -d

Test webhook đã chạy chưa:

curl http://localhost:9000/hooks/deploy-myapp

Bước 4: Kết nối với GitHub Webhook

Trên GitHub repo, vào Settings → Webhooks → Add webhook:

  • Payload URL: http://your-vps-ip:9000/hooks/deploy-myapp
  • Content type: application/json
  • Secret: Giống giá trị secret trong hooks.json
  • Events: Chọn “Just the push event”

Nếu bạn dùng Gitea (self-hosted Git), quy trình tương tự: vào repo → Settings → Webhooks → Add Webhook → Gitea.

Lưu ý bảo mật: Webhook endpoint nên được đặt sau reverse proxy (Nginx/Traefik) với HTTPS. Không nên expose port 9000 trực tiếp ra internet mà không có TLS.

GitHub Actions + Docker (nâng cao)

GitHub Actions là CI/CD platform tích hợp sẵn trong GitHub. Thay vì VPS tự build, GitHub Actions sẽ build image → push lên Docker Hub → VPS chỉ cần pull image mới về và restart.

Developer push code
        │
        ▼
GitHub Actions trigger
        │
        ├── Build Docker image
        ├── Run tests
        ├── Push image lên Docker Hub
        │
        ▼
SSH vào VPS (hoặc webhook)
        │
        ├── docker compose pull
        ├── docker compose up -d
        │
        ▼
App đã update! ✅

Ưu điểm so với webhook: build trên GitHub (nhanh, không tốn resource VPS), có test, có log chi tiết, dễ debug.

Tạo workflow file

Tạo file .github/workflows/deploy.yml trong repo:

name: Build and Deploy

on: push: branches: [main]

env: REGISTRY: docker.io IMAGE_NAME: ${{ github.repository }}

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

- name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ github.sha }}

deploy: needs: build-and-push runs-on: ubuntu-latest steps: - name: Deploy to VPS via SSH uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd /opt/myapp docker compose pull docker compose up -d docker image prune -f

Config GitHub Secrets

Vào repo GitHub → Settings → Secrets and variables → Actions, thêm các secrets:

  • DOCKERHUB_USERNAME: Username Docker Hub
  • DOCKERHUB_TOKEN: Access token (tạo tại Docker Hub Settings)
  • VPS_HOST: IP hoặc domain của VPS
  • VPS_USER: SSH username
  • VPS_SSH_KEY: Private SSH key (paste cả block -----BEGIN...END-----)

Self-hosted runner (tuỳ chọn)

Nếu bạn không muốn dùng SSH, có thể cài GitHub Actions runner trực tiếp trên VPS. Runner sẽ nhận job từ GitHub và chạy lệnh deploy ngay trên VPS.

# Trên VPS — tải và cài runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-arm64-2.321.0.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-arm64-2.321.0.tar.gz
tar xzf ./actions-runner-linux-arm64-2.321.0.tar.gz

# Config (lấy token từ repo Settings → Actions → Runners → New self-hosted runner) ./config.sh --url https://github.com/YOUR_USER/YOUR_REPO --token YOUR_TOKEN

# Chạy runner ./run.sh

Sau đó thay runs-on: ubuntu-latest bằng runs-on: self-hosted trong workflow file. Job deploy sẽ chạy trực tiếp trên VPS mà không cần SSH.

Deploy script best practices

Dù bạn dùng Watchtower, webhook hay GitHub Actions, deploy script cần được viết cẩn thận. Dưới đây là những best practices mình khuyên bạn nên áp dụng.

Health check sau deploy

Sau khi deploy xong, đừng assume app chạy ổn. Hãy kiểm tra:

#!/bin/bash
# Health check sau deploy
check_health() {
    local max_retries=10
    local retry_interval=3
    local url="http://localhost:3000/health"

for i in $(seq 1 $max_retries); do if curl -sf "$url" > /dev/null 2>&1; then log "✅ Health check passed (attempt $i)" return 0 fi log "⏳ Health check attempt $i/$max_retries failed, retrying in ${retry_interval}s..." sleep $retry_interval done

log "❌ Health check failed after $max_retries attempts" return 1 }

Rollback nếu deploy fail

Nếu health check fail → tự động rollback về version trước:

#!/bin/bash
set -e

APP_DIR="/opt/myapp" LOG_FILE="/var/log/deploy.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" }

rollback() { log "🔙 Rolling back to previous version..." cd "$APP_DIR" git checkout HEAD~1 docker compose build --no-cache docker compose up -d log "🔙 Rollback completed" }

# Deploy log "🚀 Deploy started" cd "$APP_DIR"

# Lưu commit hiện tại để rollback nếu cần PREVIOUS_COMMIT=$(git rev-parse HEAD)

git pull origin main docker compose build --no-cache docker compose up -d

# Health check sleep 5 if ! check_health; then log "❌ Deploy failed! Starting rollback..." git checkout "$PREVIOUS_COMMIT" docker compose build --no-cache docker compose up -d log "🔙 Rolled back to $PREVIOUS_COMMIT" exit 1 fi

log "✅ Deploy completed successfully!"

Notification sau deploy

Gửi thông báo để team biết deploy thành công hay thất bại:

# Gửi notification qua Telegram
notify_telegram() {
    local message="$1"
    local bot_token="YOUR_BOT_TOKEN"
    local chat_id="YOUR_CHAT_ID"

curl -s -X POST "https://api.telegram.org/bot${bot_token}/sendMessage" \ -d chat_id="$chat_id" \ -d text="$message" \ -d parse_mode="Markdown" > /dev/null }

# Gửi notification qua Discord notify_discord() { local message="$1" local webhook_url="YOUR_DISCORD_WEBHOOK_URL"

curl -s -X POST "$webhook_url" \ -H "Content-Type: application/json" \ -d "{\"content\": \"$message\"}" > /dev/null }

# Sử dụng trong deploy script if check_health; then notify_telegram "✅ *Deploy thành công!* App: myapp Commit: $(git rev-parse --short HEAD) Time: $(date '+%Y-%m-%d %H:%M:%S')" else notify_telegram "❌ *Deploy thất bại!* App: myapp Rolling back..." fi

Zero-downtime deploy

Khi bạn chạy docker compose up -d, Docker sẽ stop container cũ rồi start container mới. Trong khoảng thời gian đó, app không chạy (downtime). Để tránh, bạn có thể dùng pattern rolling update:

# docker-compose.yml
services:
  webapp:
    image: myuser/webapp:latest
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s

Lưu ý: deploy key chỉ hoạt động đầy đủ với Docker Swarm mode. Tuy nhiên, phần healthcheck vẫn hoạt động với docker compose thông thường. Nếu chưa dùng Swarm, bạn có thể dùng cách đơn giản hơn trong deploy script:

# Zero-downtime deploy đơn giản (không cần Swarm)
# Scale lên 2 instance → remove instance cũ

# 1. Build image mới docker compose build

# 2. Scale lên 2 (instance mới dùng image mới) docker compose up -d --scale webapp=2 --no-recreate

# 3. Đợi instance mới healthy sleep 10

# 4. Recreate để chỉ giữ instance mới docker compose up -d

So sánh: Watchtower vs Webhook vs GitHub Actions

Mỗi phương pháp phù hợp với hoàn cảnh khác nhau. Dưới đây là bảng so sánh:

Tiêu chíWatchtowerWebhookGitHub Actions
Độ phức tạp⭐ Rất đơn giản⭐⭐ Trung bình⭐⭐⭐ Phức tạp hơn
TriggerPoll định kỳPush codePush code
Build ở đâuKhông build (chỉ pull image)Trên VPSTrên GitHub
Rollback❌ Không có✅ Script tự viết✅ Re-run workflow
Testing❌ Không⚠️ Tuỳ script✅ Tích hợp sẵn
CostFreeFreeFree (2000 phút/tháng)
Phù hợpSide project, stagingProject vừa, self-hostedProduction, team

Gợi ý chọn:

  • Side project, blog cá nhân: Watchtower là đủ. Đơn giản, không cần suy nghĩ nhiều.
  • Project vừa, cần kiểm soát: Webhook. Push code là deploy, có script để rollback.
  • Production, team nhiều người: GitHub Actions. Có CI/CD pipeline đầy đủ, test, review, deploy.
  • Kết hợp: Dùng GitHub Actions build + push image lên registry, rồi Watchtower trên VPS tự pull image mới. Best of both worlds! 🚀

📚 Serie Docker từ A đến Z

  1. Bài 1: Docker là gì? Tại sao nên dùng Docker trên VPS
  2. Bài 2: Cài đặt Docker và Docker Compose trên VPS Ubuntu
  3. Bài 3: Làm quen với Docker – Các lệnh cơ bản cần biết
  4. Bài 4: Docker Image & Dockerfile – Tự tạo Image riêng
  5. Bài 5: Docker Volume & Network – Quản lý dữ liệu và mạng
  6. Bài 6: Docker Compose là gì? Cài đặt và cú pháp cơ bản
  7. Bài 7: Deploy WordPress + MySQL + phpMyAdmin bằng Docker Compose
  8. Bài 8: Deploy LEMP Stack (Nginx + PHP-FPM + MariaDB) bằng Docker Compose
  9. Bài 9: Biến môi trường & file .env trong Docker Compose
  10. Bài 10: Reverse Proxy với Nginx Proxy Manager + SSL tự động
  11. Bài 11: Deploy ứng dụng Node.js / Python với Docker Compose
  12. Bài 12: Backup & Restore dữ liệu Docker Volume
  13. Bài 13: Monitoring Docker với Portainer, Uptime Kuma và cAdvisor
  14. Bài 14: Docker Logging – Quản lý log hiệu quả
  15. Bài 15: Bảo mật Docker trên VPS
  16. Bài 16: CI/CD đơn giản – Auto deploy với Webhook + Docker Compose (đang đọc)
  17. Bài 17: Docker Compose trong thực tế – Tổng hợp project mẫu

Tổng kết

Trong bài này, bạn đã học:

  • CI/CD là gì: tự động hoá quy trình build, test, deploy.
  • Watchtower: auto update container khi có image mới, cực đơn giản nhưng không có rollback.
  • Webhook deploy: push code là VPS tự pull, build, deploy. Có kiểm soát, có thể thêm rollback.
  • GitHub Actions: CI/CD pipeline đầy đủ, build trên cloud, push image lên registry, deploy qua SSH.
  • Best practices: health check, rollback, notification, zero-downtime deploy.

Từ bài đầu tiên đến giờ, bạn đã đi qua gần hết những gì cần biết để chạy Docker trên VPS: từ cài đặt, viết Dockerfile, Docker Compose, network, volume, reverse proxy, monitoring, logging, security, và giờ là CI/CD.

👉 Bài 17 sẽ là bài tổng hợp project mẫu: mình sẽ cùng bạn build một ứng dụng hoàn chỉnh từ A đến Z, áp dụng tất cả kiến thức đã học trong serie. Đó sẽ là “final project” để bạn tự tin deploy bất kỳ app nào lên VPS với Docker. 🐳

👈 Bài trước: Bảo mật Docker trên VPS

👉 Bài tiếp: Docker Compose trong thực tế – Tổng hợp project mẫu

Chia sẻ:
Bài viết đã được kiểm duyệt bởi AZDIGI Team

Về tác giả

Thạch Phạm

Thạch Phạm

Đồng sáng lập và Giám đốc điều hành của AZDIGI. Có hơn 15 năm kinh nghiệm trong phổ biến kiến thức liên quan đến WordPress tại thachpham.com, phát triển website và phát triển hệ thống.

Hơn 10 năm phục vụ 80.000+ khách hàng

Bắt đầu dự án web của bạn với AZDIGI