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õ:
- Một blog content-first, nhanh, dễ đọc lâu.
- Song ngữ Việt/Anh.
- Host trên Cloudflare với domain gingatimo.com. Cloudflare Pages đang giảm đầu tư để dồn cho Workers, nên mình đi thẳng Cloudflare Workers (Static Assets).
- Publish qua tag
x.y.z. - Host thêm vài demo app cá nhân (mỗi cái là một sub-project riêng sau này).
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
- Astro (static, TypeScript strict) — khung site + content collections.
- @astrojs/mdx / rss / sitemap — nội dung, feed, sitemap.
- Shiki (built-in) — syntax highlight, cấu hình dual-theme sáng/tối.
- Pagefind — full-text search index lúc build, chạy hoàn toàn phía client.
- astro-og-canvas — sinh ảnh Open Graph cho mỗi bài lúc build.
- Giscus — bình luận dựa trên GitHub Discussions.
- ESLint (flat) + Prettier +
astro check— lint/format/type-check; husky + lint-staged chặn ở pre-commit; Vitest cho logic thuần. - Wrangler + GitHub Actions — build & deploy.
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/ và 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
- Reading time tính tự động qua một remark plugin nhỏ.
- Tags + trang từng thẻ (song ngữ).
- OG image tự động: mỗi bài có một social card 1200×630 sinh lúc build. Lưu ý quan trọng cho tiếng Việt — bộ render cần font TTF có đầy đủ dấu (mình tải Archivo variable về
public/fonts/og/), nếu không tiêu đề tiếng Việt sẽ ra ô vuông. - Search bằng Pagefind: index sinh ở bước
postbuild, kết quả giới hạn theo ngôn ngữ của trang (Pagefind tự tách index theolangcủa<html>).
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õ:
- Secrets (nhạy cảm):
CLOUDFLARE_API_TOKEN(scope tối thiểu là “Edit Cloudflare Workers”, giới hạn đúng account) vàCLOUDFLARE_ACCOUNT_ID. - Variables (công khai, được nhúng vào HTML): 4 giá trị
PUBLIC_GISCUS_*. Chúng vốn lộ trong trang nên để ở Variables, không phải Secrets.
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:
- Bật Discussions cho repo.
- Cài Giscus GitHub App cho repo.
- Tạo một category dạng Announcement (để chỉ maintainer/app mở được thread — tránh spam).
- Vào giscus.app lấy
repo-idvàcategory-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). - Đư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:
- File CSS phải được phục vụ với header CORS
Access-Control-Allow-Origin: *, vì Giscus fetch nó cross-origin từ iframe của mình. - Giscus dùng một theme mỗi lần, nên mình host hai file (dark + light) và đổi URL theo chế độ khi người dùng bấm nút theme.
// 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:
- Custom domain: gắn
gingatimo.comvào Worker (Workers → Domains & Routes → Custom Domain). Cloudflare tự cấp SSL cert. - DNS:
gingatimo.comlà zone proxied (mây cam). Thêm bản ghiwww(CNAME → apex, Proxied). - HTTPS: bật Always Use HTTPS để redirect http→https; đảm bảo Universal SSL Active.
- www → apex Redirect Rule: Rules → Redirect Rules, khớp hostname
www.gingatimo.com, dùng Dynamicconcat("https://gingatimo.com", http.request.uri.path), status 301, giữ query string. - Web Analytics: vì domain đã proxy qua Cloudflare, mình bật tự động inject (RUM Enable) — không cần token hay snippet thủ công. Component beacon trong code vẫn giữ nhưng để tắt (gate theo env), tránh đếm trùng.
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ớ
- TypeScript 7 quá mới: môi trường resolve TS 7, nhưng
@astrojs/checkvàtypescript-eslintchưa hỗ trợ. Phải pin TypeScript về~6.0.xđểastro check+ lint chạy được. Đây là “ecosystem chưa theo kịp”, không phải lint cũ. - Astro inline script không-
import→ xung đột CSP nghiêm ngặt (đã nói ở trên). - OG image cần font TTF có dấu tiếng Việt, nếu không tiêu đề ra tofu.
- Link checker
lycheeở chế độ offline không resolve được link root-relative (/about/) trên file local — phải thêm--root-dir "$(pwd)/dist". - Cache theme Giscus — nhớ purge sau khi sửa.
OGImageRoutecủa phiên bảnastro-og-canvasmình dùng trả object rỗng — phải chuyển sang hàm lõigenerateOpenGraphImagevà tự exportgetStaticPaths/GET.
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