4/8/2026 · 7 phút đọc

Xây dựng blog này: Astro tĩnh, deploy lên Cloudflare Workers qua git tag

Đây là bài đầu tiên trên gingatimo.com, và cũng là bài “meta” nhất mà mình sẽ viết: chính blog này được xây dựng như thế nào. Từ một repo chỉ chứa CV, mình biến nó thành một blog cá nhân song ngữ, host trên Cloudflare, và mỗi lần release chỉ cần đẩy một git tag vX.Y.Z.

Bài viết đi qua toàn bộ: kiến trúc, tech stack, i18n, pipeline deploy, Giscus (kèm custom theme), và những chỗ cấu hình Cloudflare mà tài liệu hay bỏ sót. Kèm luôn một mục “những cú vấp” — phần thú vị nhất.

Mục tiêu & lựa chọn nền tảng

Yêu cầu đặt ra khá rõ:

Chọn Astro ở chế độ output: 'static': prerender toàn bộ ra HTML tĩnh, ship gần như 0 JS mặc định, viết bài bằng Markdown, và tích hợp Cloudflare gọn. Không dùng adapter SSR — mọi thứ là file tĩnh, Cloudflare chỉ việc phục vụ.

Kiến trúc tổng thể

Markdown (vi/ + en/)  →  Astro build (static)  →  dist/

                          GitHub Actions (tag v*.*.*) │  wrangler deploy

                          Cloudflare Workers — Static Assets  →  gingatimo.com

Điểm cốt lõi: không cần Worker script. Cloudflare Workers Static Assets phục vụ thẳng thư mục dist/, còn hành vi (404, trailing-slash, custom headers) được cấu hình qua wrangler.jsonc và file _headers — không phải code.

// wrangler.jsonc
{
  "name": "gingatimo",
  "compatibility_date": "2026-08-01",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "404-page",
    "html_handling": "auto-trailing-slash",
  },
}

Tech stack

i18n: tiếng Việt ở gốc, tiếng Anh ở /en/

Dùng i18n có sẵn của Astro, mặc định tiếng Việt ở root, tiếng Anh dưới tiền tố:

// astro.config.mjs
export default defineConfig({
  site: 'https://gingatimo.com',
  output: 'static',
  i18n: {
    defaultLocale: 'vi',
    locales: ['vi', 'en'],
    routing: { prefixDefaultLocale: false },
  },
});

Mỗi bài viết là một cặp file cùng tên ở vi/en/, nối với nhau bằng translationKey trong frontmatter. Việc dùng chung slug (cùng tên file) giúp @astrojs/sitemap tự sinh hreflang alternates chính xác, và mình bổ sung x-default trỏ về bản tiếng Việt. Build sẽ fail nếu có translationKey trùng trong một ngôn ngữ hoặc lệch slug giữa cặp — một “hàng rào” nhỏ chống lỗi nội dung.

Nút chuyển ngôn ngữ chỉ hiện khi bài có bản dịch tương ứng.

Nội dung & tính năng

Deploy: GitHub Actions theo tag vX.Y.Z

Release = tạo và đẩy một tag. Workflow chỉ kích hoạt với tag khớp v*.*.*:

# .github/workflows/deploy.yml (rút gọn)
on:
  push:
    tags: ['v*.*.*']
permissions:
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version-file: .nvmrc, cache: npm }
      - run: npm ci
      - run: npm run verify # astro check + eslint + prettier
      - name: Build
        env:
          PUBLIC_SITE_VERSION: ${{ github.ref_name }}
          PUBLIC_GISCUS_REPO_ID: ${{ vars.PUBLIC_GISCUS_REPO_ID }}
          # …các PUBLIC_GISCUS_* khác
        run: npm run build
      - uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: deploy

Secrets vs Variables — phân biệt rõ:

Tag vX.Y.Z cũng được inject vào footer (PUBLIC_SITE_VERSION = github.ref_name) để biết bản nào đang chạy. Một workflow CI riêng chạy trên pull request: astro check, lint, format, build, và kiểm tra link nội bộ.

Giscus: bình luận qua GitHub Discussions

Các bước một lần:

  1. Bật Discussions cho repo.
  2. Cài Giscus GitHub App cho repo.
  3. Tạo một category dạng Announcement (để chỉ maintainer/app mở được thread — tránh spam).
  4. Vào giscus.app lấy repo-idcategory-id, đặt mapping = pathname (mỗi URL bài → một thread; vì song ngữ nên bản VI và EN có thread tách biệt).
  5. Đưa 4 giá trị vào GitHub Variables.

Component Giscus lazy-load (chỉ tải khi cuộn tới, dùng IntersectionObserver) và đồng bộ theme sáng/tối với site qua postMessage.

Cú vấp lớn nhất: CSP chặn inline script

Mình đặt một Content-Security-Policy nghiêm ngặt (không 'unsafe-inline'). Sau khi deploy, nút đổi theme không hoạt động và console báo lỗi CSP chặn một inline script.

Nguyên nhân: Astro nhúng thẳng (inline) các `<script>` không có import vào HTML như một tối ưu. Với CSP script-src 'self', đoạn inline đó bị chặn → handler không gắn được.

Cách sửa đúng: đưa JS client ra file external trong public/ và load qua `<script is:inline src="…">`. External = hợp lệ với 'self', không cần nới 'unsafe-inline'. (Script có import thì Astro tự bundle ra /_astro/*.js external, nên không dính lỗi này.)

Custom Giscus theme khớp brand

Theme mặc định của Giscus mang tông GitHub (xanh). Mình muốn nó khớp palette “sci-fi instrument” của blog (near-black ấm + accent hổ phách). Giscus cho phép custom theme bằng URL tới một file CSS.

Cách làm gọn nhất là lấy theme gốc noborder_dark / noborder_light của Giscus rồi đổi hai biến chủ đạo:

main {
  --primary-default: 232, 182, 120; /* amber #e8b678 */
  --bg-default: 10, 9, 8; /* near-black #0a0908 */
  /* …các biến khác tham chiếu hai biến trên */
}

Hai điểm dễ quên:

// public/giscus.js
const theme = () =>
  document.documentElement.dataset.theme === 'light'
    ? 'https://gingatimo.com/giscus-theme-light.css'
    : 'https://gingatimo.com/giscus-theme.css';

Và một cú vấp nhỏ: sau khi sửa file theme, phải purge cache Cloudflare cho /giscus-theme*.css — Giscus fetch URL cố định nên dễ nhận bản cũ đã cache.

Cấu hình Cloudflare

Phần này không nằm trong repo mà làm trên dashboard:

Bảo mật: CSP + headers qua _headers

Toàn bộ được khai báo trong public/_headers (Cloudflare Static Assets đọc file này):

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://giscus.app https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://giscus.app; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://cloudflareinsights.com; frame-src https://giscus.app; base-uri 'self'; object-src 'none'; frame-ancestors 'none'

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

'wasm-unsafe-eval' là cho WASM của Pagefind; giscus.app được allowlist cho cả script-src (client.js), style-src (stylesheet của widget) và frame-src (iframe).

Những cú vấp đáng nhớ

Kết

Kết quả: một blog tĩnh, song ngữ, nhanh, có search, comment khớp brand, SEO đầy đủ — và quy trình release chỉ gọn trong một dòng:

git tag v1.0.0 && git push origin v1.0.0

GitHub Actions lo phần còn lại: verify → build → index search → wrangler deploy. Footer hiện đúng version vừa deploy.

Nếu bạn cũng định dựng một blog tĩnh trên Cloudflare Workers, hy vọng danh sách “những cú vấp” ở trên tiết kiệm cho bạn vài giờ. Có gì cứ để lại bình luận bên dưới.

Thẻ: Astro · Cloudflare · DevOps · Meta