Docker
Docker Compose
ghcr
CI/CD
DevOps

Docker Compose & ghcr trong CI/CD: từ build image tới container chạy trên VPS 🐳

Pin Nguyen

Pin Nguyen

Software Developer

July 12, 2026

8 min read

Docker Compose & ghcr trong CI/CD: từ build image tới container chạy trên VPS 🐳

Trong bài trước mình kể tổng thể pipeline CI/CD. Bài này zoom vào hai mảnh ghép làm nên phần "chạy được trên VPS": Docker Compose (dựng container) và ghcr.io (kho chứa image). Mình sẽ giải thích thật chậm, từ khái niệm gốc, để ai chưa quen Docker cũng theo được.

1. Ba khái niệm gốc: Dockerfile → Image → Container

Rất nhiều người lẫn lộn ba thứ này. Ví von cho dễ:

Khái niệm Là gì Ví von
Dockerfile Công thức: cài gì, copy gì, chạy lệnh nào Công thức nấu ăn
Image Sản phẩm đóng gói bất biến từ Dockerfile Hộp cơm đã nấu, đóng gói kín
Container Một image đang chạy Hộp cơm được mở ra ăn

Từ một image có thể chạy nhiều container giống hệt nhau — như từ một hộp cơm mẫu nhân bản ra nhiều phần. Image bất biến (build xong không đổi), nên "chạy được trên máy tôi" thì chạy được ở mọi nơi.

2. Vấn đề: build ở đâu, chạy ở đâu?

Trong dự án này:

  • Máy Mac build image (vì VPS 1GB yếu, không build nổi Next.js).
  • VPS chạy container.

Vậy phải có cách chuyển image từ Mac sang VPS. Có hai lối:

  1. Bắn thẳng qua SSH: docker save | ssh | docker load — nén image thành file rồi gửi. Đơn giản nhưng mỗi lần gửi lại toàn bộ, tốn băng thông.
  2. Qua Registry (cách dự án dùng): đẩy image lên một "kho" trung gian, VPS kéo về. Đây là cách chuẩn công nghiệp.

3. ghcr.io — cái "kho" ở giữa

GitHub Container Registry (ghcr.io) là kho lưu image gắn liền tài khoản GitHub, miễn phí không giới hạn với image để public. Luồng:

Mac:  docker build ──► docker push ──► ghcr.io/pinnguyen9x/learn-nextjs:prod-14
                                            │
VPS:                        docker pull ◄──┘  ──► docker compose up

Lệnh cụ thể phía CI (trên Mac):

# Mac arm64 build cho VPS amd64 -> phải chỉ định platform
docker build --platform linux/amd64 --provenance=false \
  -t ghcr.io/pinnguyen9x/learn-nextjs:prod-14 .

# $TOKEN = GitHub PAT (classic) có scope write:packages
echo "$TOKEN" | docker login ghcr.io -u pinnguyen9x --password-stdin
docker push ghcr.io/pinnguyen9x/learn-nextjs:prod-14

Lưu ý phần tên image: ghcr.io/<chủ sở hữu>/<tên package>:<tag> — phần <chủ sở hữu> phải là username hoặc organization GitHub và viết thường, nếu không push sẽ bị từ chối.

Vài điểm đáng nhớ khi "ship" lên ghcr:

  • Tag (prod-14) là số phiên bản — mỗi build một tag, để biết chính xác cái gì đang chạy và rollback được.
  • --platform linux/amd64: Mac là ARM, VPS là x86 — không khai báo là image chạy sai kiến trúc.
  • Image public: đặt package public thì VPS pull không cần đăng nhập (chỉ push mới cần token). Nếu để private, VPS cũng phải docker login ghcr.io bằng token có quyền đọc package.
  • Quyền của token: PAT dùng để push cần scope write:packages. Nếu chạy trong GitHub Actions thì dùng luôn secrets.GITHUB_TOKEN, chỉ cần khai permissions: packages: write trong workflow.

4. Docker Compose — "bản khai báo" container

Chạy một container bằng docker run với cả tá cờ (-p, -e, -v, --restart...) rất dài và dễ sai. Docker Compose cho phép khai báo mọi thứ trong một file YAML, rồi chỉ cần docker compose up.

File docker-compose.yml của frontend (rút gọn, đã tham số hóa cho cả prod/staging):

services:
  web:
    image: ${IMAGE:-ghcr.io/pinnguyen9x/learn-nextjs:latest}  # image kéo từ ghcr
    container_name: ${CONTAINER:-learn-nextjs}
    restart: unless-stopped         # tự bật lại khi crash / reboot
    ports:
      - "${HOST_PORT:-3000}:3000"    # cổng host : cổng trong container
    environment:
      - API_URL=${API_TARGET:-http://json-server-blog:4000}  # gọi backend qua tên container
      - HOSTNAME=0.0.0.0
    networks:
      - webnet                        # mạng nội bộ để frontend gặp backend
    healthcheck:                      # Docker tự kiểm tra container còn sống không
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]
      interval: 30s

networks:
  webnet:
    external: true

Đọc file này như đọc một tờ khai:

  • image: kéo bản nào từ ghcr (biến ${IMAGE} do CI truyền vào lúc deploy).
  • ports "${HOST_PORT:-3000}:3000": map cổng của VPS (mặc định 3000, staging truyền 3001) vào cổng 3000 trong container. Cú pháp ${BIẾN:-giá trị mặc định} nghĩa là "lấy biến môi trường, không có thì dùng giá trị sau dấu :-".
  • restart: unless-stopped: container chết là Docker tự dựng lại — không cần người canh.
  • environment: bên trái dấu = là tên biến mà app đọc bên trong container (API_URL), bên phải là giá trị lấy từ biến API_TARGET ở môi trường chạy lệnh compose. HOSTNAME=0.0.0.0 để Next.js standalone lắng nghe mọi địa chỉ, nếu không nó bind vào hostname của container và bên ngoài không gọi vào được.
  • networks: webnet: cả frontend và backend cùng một mạng ảo, nên frontend gọi backend bằng tên container (json-server-blog) thay vì IP. Nhờ vậy backend không cần mở cổng ra internet — ẩn hoàn toàn. Khai external: true nghĩa là mạng này do bạn tạo sẵn ngoài compose (docker network create webnet), không phải compose tự dựng — chưa tạo thì up sẽ báo lỗi không tìm thấy network.
  • healthcheck: Docker định kỳ "bắt mạch" container; hỏng thì báo unhealthy.

5. Khoan — file docker-compose.yml nằm ở đâu trên VPS?

Câu hỏi rất dễ nhầm: docker compose up đọc file docker-compose.yml trong thư mục nó đang đứng — vậy file đó ở đâu ra trên VPS? Bạn không viết tay nó trên server.

File gốc nằm trong Git repo (có version), và pipeline tự copy (scp) nó lên VPS ngay trước khi chạy. Nhìn vào stage Deploy của Jenkins:

# 1. Ship file compose TỪ repo (workspace Jenkins) LÊN VPS
scp docker-compose.yml  pin@vps:/opt/learn-nextjs/docker-compose.yml
# 2. Vào đúng thư mục đó rồi chạy, kèm biến IMAGE cho tag vừa build
ssh pin@vps "cd /opt/learn-nextjs && \
  IMAGE=ghcr.io/pinnguyen9x/learn-nextjs:prod-14 docker compose pull && \
  IMAGE=ghcr.io/pinnguyen9x/learn-nextjs:prod-14 docker compose up -d"

Biến IMAGE phải có mặt ở cả hai lệnh: pullup đều đọc lại docker-compose.yml, thiếu biến là compose rơi về giá trị mặc định :latest và bạn deploy nhầm bản. (Cách gọn hơn: ghi biến vào file .env cạnh docker-compose.yml trên VPS — compose tự đọc file này.)

Luồng đầy đủ:

docker-compose.yml (Git repo)  ──scp──►  /opt/.../docker-compose.yml (VPS)  ──compose up──►  container
     ↑ nơi bạn SỬA                            ↑ bản triển khai, bị ghi đè mỗi lần deploy

Điểm mấu chốt: bản trên VPS chỉ là "bản sao được ship tới", bị scp đè mỗi lần deploy. Muốn đổi cấu hình vĩnh viễn thì sửa trong repo rồi push — nếu sửa tay trên VPS, lần deploy sau bị đè mất. Giống hệt image: không nằm cố định trên VPS mà pull từ ghcr; compose file cũng đến từ repo. Cả "cái gì chạy" (image) lẫn "chạy thế nào" (compose) đều bắt nguồn từ Git.

6. Ghép lại: một lần deploy trên VPS

Sau khi image đã ở trên ghcr, phần deploy trên VPS gọn lỏn:

export IMAGE=ghcr.io/pinnguyen9x/learn-nextjs:prod-14
docker compose pull            # kéo image mới từ ghcr
docker compose up -d           # dựng/thay container theo docker-compose.yml
docker image prune -af         # dọn image cũ không dùng
  • pull: lấy đúng tag CI vừa đẩy.
  • up -d: Compose so sánh tờ khai với thực tế, container nào đổi thì tạo lại (-d = chạy nền).
  • prune -af: xóa image cũ để đĩa VPS không phình.

7. Vì sao tách image (ghcr) khỏi cách chạy (compose)?

Điểm hay của mô hình này:

  • Image bất biến trên ghcr = "cái gì chạy" — có phiên bản, rollback được.
  • docker-compose.yml = "chạy như thế nào" — cổng, biến môi trường, mạng.

Hai thứ tách bạch: đổi phiên bản app chỉ là đổi tag image; đổi cấu hình chạy chỉ là sửa compose. Cùng một image prod-14 có thể chạy ở prod (cổng 3000) hay staging (cổng 3001) chỉ bằng cách truyền biến khác — không build lại.

8. Kết

Docker Compose + Registry là bộ đôi biến việc deploy từ "ssh vào gõ một tràng lệnh" thành "kéo image + đọc một tờ khai". Nắm được:

  • Image là sản phẩm bất biến, sống trên registry (ghcr), có tag/phiên bản.
  • Compose là bản khai báo cách dựng container từ image đó.

...là bạn đã nắm xương sống của gần như mọi hệ thống deploy container hiện đại — dù nhỏ như VPS này hay lớn như cả một hạ tầng doanh nghiệp.

Pin Nguyen

Pin Nguyen

July 12, 2026
8 min read

Back to Blog

pin nguyen cute

Full-stack engineer building data platforms and big-data systems — from real-time pipelines and scalable APIs to polished user interfaces.

Available for work

Connect

nguyenthanhpin95@gmail.com

© 2026 Pin Nguyen. Built with Next.js & TypeScript.