❤️ 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.

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à:
- SSH vào server
- Git pull code mới
- Build image
- Restart container
- Kiểm tra xem chạy ổn không
- 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 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

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 composecommands. - Mount app directory để script có thể
git pull. -hotreloadcho 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ị
secrettrong 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 HubDOCKERHUB_TOKEN: Access token (tạo tại Docker Hub Settings)VPS_HOST: IP hoặc domain của VPSVPS_USER: SSH usernameVPS_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í | Watchtower | Webhook | GitHub Actions |
|---|---|---|---|
| Độ phức tạp | ⭐ Rất đơn giản | ⭐⭐ Trung bình | ⭐⭐⭐ Phức tạp hơn |
| Trigger | Poll định kỳ | Push code | Push code |
| Build ở đâu | Không build (chỉ pull image) | Trên VPS | Trên GitHub |
| Rollback | ❌ Không có | ✅ Script tự viết | ✅ Re-run workflow |
| Testing | ❌ Không | ⚠️ Tuỳ script | ✅ Tích hợp sẵn |
| Cost | Free | Free | Free (2000 phút/tháng) |
| Phù hợp | Side project, staging | Project vừa, self-hosted | Production, 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
- Bài 1: Docker là gì? Tại sao nên dùng Docker trên VPS
- Bài 2: Cài đặt Docker và Docker Compose trên VPS Ubuntu
- Bài 3: Làm quen với Docker – Các lệnh cơ bản cần biết
- Bài 4: Docker Image & Dockerfile – Tự tạo Image riêng
- Bài 5: Docker Volume & Network – Quản lý dữ liệu và mạng
- Bài 6: Docker Compose là gì? Cài đặt và cú pháp cơ bản
- Bài 7: Deploy WordPress + MySQL + phpMyAdmin bằng Docker Compose
- Bài 8: Deploy LEMP Stack (Nginx + PHP-FPM + MariaDB) bằng Docker Compose
- Bài 9: Biến môi trường & file .env trong Docker Compose
- Bài 10: Reverse Proxy với Nginx Proxy Manager + SSL tự động
- Bài 11: Deploy ứng dụng Node.js / Python với Docker Compose
- Bài 12: Backup & Restore dữ liệu Docker Volume
- Bài 13: Monitoring Docker với Portainer, Uptime Kuma và cAdvisor
- Bài 14: Docker Logging – Quản lý log hiệu quả
- Bài 15: Bảo mật Docker trên VPS
- Bài 16: CI/CD đơn giản – Auto deploy với Webhook + Docker Compose (đang đọc)
- 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
Có thể bạn cần xem thêm
Về tác giả
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.