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:
- 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. - 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
pullkhông cần đăng nhập (chỉpushmới cần token). Nếu để private, VPS cũng phảidocker login ghcr.iobằng token có quyền đọc package. - Quyền của token: PAT dùng để
pushcần scopewrite:packages. Nếu chạy trong GitHub Actions thì dùng luônsecrets.GITHUB_TOKEN, chỉ cần khaipermissions: packages: writetrong 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ếnAPI_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. Khaiexternal: truenghĩ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ìupsẽ 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: pull và up đề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.