← Blog

Tự host với Docker Compose, Caddy và GitHub self-hosted runner

Backend và vận hành

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 main trừ 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_on có điều kiện thay cho các lệnh sleep.
  • 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