Tự host với Docker Compose, Caddy và GitHub self-hosted runner
Cách dailerc.com chạy trên một máy chủ riêng: một origin duy nhất sau Caddy, các dịch vụ trong Docker Compose có healthcheck, và mỗi commit lên main được deploy tự động bởi một runner tự host, có backup và rollback.
Nền tảng đám mây tiện, nhưng với một website và một hệ thống nội bộ, một máy chủ riêng thường đủ dùng, dễ đoán chi phí và giữ dữ liệu trong tay mình. Bài này mô tả cách dailerc.com được triển khai: Docker Compose, Caddy và một GitHub Actions runner chạy ngay trên máy chủ.
Một origin, Caddy đứng trước
Website công khai, trang quản trị, API và WebSocket đều nằm trên cùng một tên miền. Điều này giúp cookie SameSite=Strict hoạt động đơn giản và không cần cấu hình CORS phức tạp. Caddy tự xin và gia hạn chứng chỉ Let's Encrypt, rồi định tuyến theo đường dẫn:
@api path /api/v1/* /socket.io/*
handle @api {
reverse_proxy api:4000
}
handle_path /media/* {
reverse_proxy minio:9000
}
handle {
reverse_proxy web:3000
}
Ảnh và tệp đi qua /media/* tới MinIO, chỉ cho phép GET và HEAD; mọi phương thức khác trả 405, và header Authorization bị bỏ trước khi chuyển tiếp. Vì mỗi tệp tải lên có khóa duy nhất, phản hồi thành công được cache một năm với immutable. Caddy cũng thêm HSTS, nosniff, nén zstd/gzip, và trả 404 cho route revalidate nội bộ của Next.js.
Docker Compose với healthcheck và thứ tự khởi động
File docker-compose.prod.yml gồm Caddy, Next.js, API NestJS, PostgreSQL 16, Redis 7 (bật append-only để không mất dữ liệu khi khởi động lại), MinIO và một dịch vụ migrate chạy một lần. Thứ tự được ràng buộc bằng điều kiện:
api:
depends_on:
migrate: { condition: service_completed_successfully }
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
API chỉ khởi động sau khi migration chạy xong, và web chỉ khởi động khi API báo khỏe qua /api/v1/health. Các dịch vụ nằm trong một mạng nội bộ; chỉ Caddy mở cổng ra ngoài. Log dùng driver json-file giới hạn 10 MB mỗi tệp, giữ 5 tệp, để ổ đĩa không bị đầy.
GitHub Actions trên runner tự host
Workflow deploy chạy trên một runner cài trên chính máy chủ, nên không cần mở SSH cho dịch vụ bên ngoài:
on:
push: { branches: [main] }
workflow_dispatch:
concurrency: { group: deploy-production, cancel-in-progress: false }
jobs:
deploy:
runs-on: [self-hosted, dailerc-deploy]
steps:
- run: ~/dailerc-tech-deploy/scripts/deploy.sh "$REF"
concurrency đảm bảo mỗi lúc chỉ có một lần deploy, và lần deploy đang chạy không bị hủy giữa chừng. Workflow chỉ chạy khi có push lên main hoặc khi được chạy tay, không bao giờ chạy mã từ pull request. Đây là điều kiện quan trọng khi dùng runner tự host, vì runner có quyền trên máy chủ thật.
Script deploy làm gì
- Lấy khóa để không có hai lần deploy song song, và tự nhận lại khóa nếu tiến trình cũ đã chết.
- Đưa thư mục deploy về đúng commit, từ chối commit không nằm trên
maintrừ khi bật cờ force. - Kiểm tra biến môi trường, rồi backup PostgreSQL (giữ 7 bản gần nhất) và dữ liệu MinIO trước khi migration chạy.
- Build image gắn tag bằng mã commit; nếu image đã tồn tại thì bỏ qua bước build.
- Chạy
docker compose up, chờ từng dịch vụ báo khỏe, rồi reload Caddy. - Smoke test qua Caddy bằng
curl --resolve, nên kiểm tra cả chứng chỉ mà không phụ thuộc DNS. - Gọi revalidate cache của Next.js từ bên trong container web, rồi xóa image cũ nhưng giữ lại vài bản gần nhất.
Khi một bước lỗi, script dừng ngay, in trạng thái và log của dịch vụ liên quan.
Rollback chỉ là deploy một commit cũ
Vì image được gắn tag theo commit và vài bản gần nhất được giữ lại, rollback là chạy tay workflow với mã của commit cũ: không cần build lại, nên rất nhanh. Có một giới hạn cần biết: migration chỉ đi tiến. Nếu bản mới đã đổi schema theo cách bản cũ không đọc được, bản backup tạo trước migration là đường lui.
Bài học
- Một origin phía sau một reverse proxy giúp cookie, CORS và HTTPS đơn giản hơn nhiều.
- Dùng healthcheck và
depends_oncó điều kiện thay cho các lệnhsleep. - Luôn backup trước migration, và smoke test qua đúng đường người dùng đi vào.
- Runner tự host chỉ nên chạy mã đã vào nhánh chính, không chạy mã từ pull request.
Bài viết liên quan
Giới hạn tần suất và khóa đăng nhập trong NestJS với Redis
Hai lớp bảo vệ cho API: giới hạn request theo IP bằng @nestjs/throttler lưu trong Redis, và khóa tạm thời theo email sau nhiều lần đăng nhập sai, không làm lộ tài khoản nào tồn tại.
Đọc tiếpNhận thanh toán chuyển khoản VietQR và xác nhận qua webhook
Thiết kế luồng thanh toán VietQR cho Dailerc Tools Store: mã đơn trong nội dung chuyển khoản, webhook được xác thực, kiểm tra số tiền chính xác, chống xử lý trùng và phương án xác nhận thủ công.
Đọc tiếpISR và revalidate theo yêu cầu trong Next.js 15
Cách website dailerc.com vừa phục vụ trang tĩnh nhanh, vừa cập nhật ngay khi quản trị viên sửa nội dung: fetch có tag, revalidateTag sau mỗi thay đổi và một route revalidate được bảo vệ cho lúc deploy.
Đọc tiếp