← Blog

git pull + docker compose up -d không thấy thay đổi? — 5 cú vấp tôi đốt 30 phút mỗi lần

SSH vào VPS, git pull, docker compose up -d... và code mới vẫn không lên. Đây là 5 thủ phạm tôi đã đốt thời gian thật cho từng cái — và checklist 60 giây để bắt đúng cái nào đang cắn bạn.

Quy trình của tôi mỗi lần ship code lên một VPS nhỏ:

ssh deploy@vps
cd /srv/myapp
git pull
docker compose up -d

Bốn dòng. 30 giây nếu mọi thứ ngon. Nhưng có những lần tôi git pull xong, docker compose up -d chạy không lỗi gì cả — log nói myapp-web is up-to-date — rồi mở browser thấy vẫn là code cũ. Curl endpoint cũng ra response cũ. Container chạy đó, nhưng nó chạy image của 3 commit trước.

Lần đầu gặp tôi reload trình duyệt, tắt CDN cache, đổi cả browser. Sau đó tôi mới nhận ra: vấn đề không phải ở client. Vấn đề là Docker chưa hề biết code đã đổi.

Sau 4 năm dùng Docker để deploy mấy site freelance, đây là 5 cú vấp tôi gặp đi gặp lại — mỗi cái đều từng đốt của tôi tròn trĩnh 30 phút trước khi tôi học được pattern.

Logo Docker

Cú vấp 1: docker compose up -d mặc định không rebuild image

Đây là cú lừa số 1 và 90% người mới Docker đều dính.

Khi bạn chạy docker compose up -d, Compose hỏi: “image cho service này đã tồn tại chưa?”. Nếu đã tồn tại (kể cả là image build từ commit cũ), nó dùng lại. Nó không kiểm tra xem Dockerfile có đổi không, không kiểm tra source code có đổi không. Image còn — dùng lại, hết.

Nó chỉ rebuild khi:

  1. Image chưa tồn tại (lần đầu hoặc bạn đã docker rmi).
  2. Bạn truyền cờ --build.
  3. Bạn chạy docker compose build trước.

Fix: thêm --build thành thói quen vĩnh viễn:

docker compose up -d --build

Hoặc nếu bạn muốn tách 2 bước (rebuild rõ ràng hơn, log đẹp hơn):

docker compose build
docker compose up -d

Tôi viết hẳn alias trong ~/.bashrc của user deploy trên mọi VPS:

alias dredeploy='git pull && docker compose build && docker compose up -d && docker compose logs -f --tail=50'

logs -f --tail=50 ở cuối là để tôi không phải gõ thêm lệnh thứ 5 sau khi đã gõ 4 lệnh.

Cú vấp 2: Volume mount đè lên build artifact

Tình huống: Dockerfile của bạn build frontend (npm run builddist/) và copy dist/ vào image. Đẹp. Nhưng trong docker-compose.yml bạn lại có:

services:
  web:
    build: .
    volumes:
      - ./:/app

Bạn nghĩ “mount source code vào để dev tiện” — đúng cho dev. Nhưng ./ trên VPS không có thư mục dist/ (vì git ignore nó). Khi container start, volume mount đè lên /app — cuốn bay luôn dist/ vừa build trong image. Container chạy với folder rỗng.

Triệu chứng đặc trưng: docker compose logs báo ENOENT: dist/index.html, hoặc nginx 404 file tĩnh.

Fix 1 — Tách docker-compose.yml (cho dev có volume mount) và docker-compose.prod.yml (không mount source):

# docker-compose.prod.yml
services:
  web:
    build: .
    # KHÔNG có volumes: trỏ vào source code
    ports:
      - "3000:3000"

Deploy bằng docker compose -f docker-compose.prod.yml up -d --build.

Fix 2 — Dùng named volume cho thư mục cần persist (uploads, db data) thay vì bind mount toàn project:

volumes:
  - uploads_data:/app/storage/uploads
  # KHÔNG: - ./:/app

Tôi luôn đi với fix 1. Tách rạch ròi dev / prod đỡ đau đầu hơn if NODE_ENV rải khắp file compose.

Cú vấp 3: .env rò rỉ vào image — hoặc thiếu trong runtime

Đây là cú vấp 2 chiều. Bạn dính 1 trong 2 hướng tuỳ cách bạn cấu hình.

Hướng A — Thiếu .env: bạn có .dockerignore chứa .env* (đúng — không nên build secrets vào image). Nhưng trong docker-compose.yml bạn cũng quên khai báo env_file:. Container chạy với process.env.DATABASE_URL = undefined, app crash khi connect DB.

Hướng B — Leak secrets vào image: bạn COPY . . trong Dockerfile mà không có .dockerignore, hoặc .dockerignore không có .env*. Image build ra có file .env nằm trong /app/.env — bất kỳ ai pull được image (registry public, hoặc backup) đều đọc được DB password.

Fix — chuẩn hoá pattern này:

.dockerignore của tôi:

node_modules
.git
.env
.env.*
!.env.example
dist
*.log

docker-compose.yml:

services:
  web:
    build: .
    env_file:
      - .env       # đọc từ file ngoài image, runtime inject

Bạn đặt .env trên VPS qua SCP / Ansible / chmod 600. Image vẫn sạch, container vẫn có biến môi trường lúc start. Best of both.

Cú vấp 4: Image cũ ngốn ổ cứng → next pull fail vì hết disk

VPS của tôi 40 GB. Sau 6 tháng deploy 1 site nhỏ, docker system df:

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          47        2         28.3GB    27.1GB (95%)
Containers      4         2         234MB     185MB (79%)
Local Volumes   8         3         1.2GB     800MB (66%)
Build Cache     312       0         8.4GB     8.4GB (100%)

38 GB rác. Mỗi docker compose build mới tạo một image mới mà không xoá image cũ — cứ rebuild 47 lần là có 47 image. Build cache cũng tích trữ tương tự.

Triệu chứng đặc trưng: git pull báo error: unable to write file: No space left on device. Hoặc docker compose build báo lỗi tương tự ở giữa quá trình.

Fix định kỳ — chạy 1 tuần 1 lần (cron, hoặc thủ công sau mỗi đợt deploy lớn):

# Xoá image không có container nào tham chiếu
docker image prune -a -f

# Xoá build cache > 7 ngày
docker builder prune -a -f --filter "until=168h"

# Xoá volume mồ côi (CẨN THẬN — chỉ chạy khi chắc chắn)
docker volume prune -f

Tôi cho dòng đầu vào cron hàng tuần:

0 3 * * 0 docker image prune -a -f >> /var/log/docker-cleanup.log 2>&1

3 giờ sáng Chủ Nhật — không ai tunneling lúc đó.

Cảnh báo: docker volume prune xoá volume không gắn với container nào. Nếu DB của bạn dùng named volume mà bạn docker compose down (KHÔNG kèm -v), volume vẫn ở đó — prune không động. Nhưng nếu bạn lỡ docker compose down -v rồi prune, xoá DB. Backup trước khi prune lần đầu.

Cú vấp 5: Build trên Mac M1, deploy lên VPS x86 — image silently broken

Cú này hiếm hơn nhưng khi dính thì rất khó debug vì không có lỗi gì cả lúc build. Container chỉ exit ngay sau khi start với exec format error.

Nguyên nhân: docker build trên Mac Apple Silicon mặc định build cho linux/arm64. VPS phổ biến (Vultr, DigitalOcean, Hetzner shared) chạy linux/amd64. Image arm64 không chạy được trên amd64.

Cách tôi gặp: build local rồi push lên Docker Hub, pull về VPS, container exit ngay. Log có dòng cryptic exec /usr/local/bin/node: exec format error.

Fix — luôn build với --platform rõ ràng nếu bạn build local:

docker build --platform linux/amd64 -t myimage .
docker push myimage

Hoặc trong docker-compose.yml:

services:
  web:
    build:
      context: .
      platforms:
        - linux/amd64

Fix tốt hơn — đừng build local, build trên chính VPS. git pull rồi docker compose build chạy native trên CPU đích, không bao giờ sai platform. Đây là pattern tôi giữ cho mọi project freelance — chỉ build local khi đang dev.

Checklist 60 giây khi deploy không lên

Tôi đi theo thứ tự này, mỗi bước < 10 giây:

# 1. Image có thực sự rebuild không?
docker compose images        # xem image ID + tag
# nếu image ID không đổi sau khi rebuild → cú vấp 1

# 2. Container chạy đúng image vừa build chứ?
docker compose ps
docker inspect <container> | grep Image

# 3. Code trong container có đúng commit hiện tại không?
docker compose exec web cat /app/package.json | grep version
# hoặc
docker compose exec web ls -la /app/dist/

# 4. Volume có đè lên build artifact không?
docker compose config | grep -A2 volumes

# 5. Disk còn không?
df -h /
docker system df

Bước 1 + bước 3 bắt được 80% case. Bước 5 bắt nốt 15%. Còn 5% là .env thiếu / sai (cú vấp 3) — log container sẽ nói cho bạn biết.

Template docker-compose.prod.yml của tôi

Sau 4 năm, đây là khung tôi luôn copy cho project freelance mới:

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
      platforms:
        - linux/amd64
    image: myapp:latest
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "127.0.0.1:3000:3000"  # chỉ expose qua nginx, không expose ra ngoài
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"

volumes:
  db_data:

Vài điểm tôi luôn giữ:

  • image: myapp:latest — đặt tag rõ ràng để docker image prune -a không xoá nhầm.
  • restart: unless-stopped — VPS reboot, container tự lên lại.
  • 127.0.0.1: trên ports — không expose database hay app trực tiếp ra Internet, đi qua nginx local.
  • healthcheckdocker compose ps báo unhealthy thay vì im lặng khi app crash.
  • Không có volumes: trỏ vào source code — đã giải quyết cú vấp 2.

Lời cuối

Docker là công cụ tuyệt vời để chuẩn hoá môi trường — git clonedocker compose up chạy là tôi ngủ ngon. Nhưng đúng như cron, Docker đầy bẫy ẩn: up -d mà không rebuild, volume đè artifact, image cũ ngấu ổ cứng, platform mismatch im như tờ.

5 cú vấp này phủ ~95% các lần “deploy không lên” tôi đã debug. Còn 5% còn lại thường là DNS / nginx upstream / network — nhưng đó là post khác.

Lần tới khi bạn git pull && docker compose up -d mà code mới không lên, hãy đi qua 5 dòng docker compose images / ps / exec / config / df ở phía trên. 60 giây tiết kiệm bạn 30 phút.