8/4/2026 · 7 min read
Building this blog: static Astro, deployed to Cloudflare Workers via git tags
This is the first post on gingatimo.com, and also the most “meta” one I’ll write: how this blog itself was built. It started as a repo that only held my CV, and turned into a bilingual personal blog hosted on Cloudflare — where every release is just a git tag vX.Y.Z.
The post walks through all of it: architecture, tech stack, i18n, the deploy pipeline, Giscus (with a custom theme), and the Cloudflare config the docs tend to gloss over. Plus a “gotchas” section — the fun part.
Goals & platform choices
The requirements were clear enough:
- A content-first blog: fast, comfortable for long reads.
- Bilingual Vietnamese/English.
- Hosted on Cloudflare with the gingatimo.com domain. Cloudflare Pages is being de-emphasized in favor of Workers, so I went straight to Cloudflare Workers (Static Assets).
- Publish via a
x.y.ztag. - Room to host a few personal demo apps (each its own future sub-project).
I picked Astro with output: 'static': prerender everything to static HTML, ship near-zero JS by default, author in Markdown, and integrate cleanly with Cloudflare. No SSR adapter — everything is a static file and Cloudflare just serves it.
The big picture
Markdown (vi/ + en/) → Astro build (static) → dist/
│
GitHub Actions (tag v*.*.*) │ wrangler deploy
▼
Cloudflare Workers — Static Assets → gingatimo.com
The key point: no Worker script needed. Cloudflare Workers Static Assets serves the dist/ folder directly, and behavior (404s, trailing slashes, custom headers) is configured through wrangler.jsonc and a _headers file — not 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) — the site + content collections.
- @astrojs/mdx / rss / sitemap — content, feeds, sitemap.
- Shiki (built-in) — syntax highlighting with a dual light/dark theme.
- Pagefind — full-text search indexed at build time, fully client-side.
- astro-og-canvas — per-post Open Graph images generated at build.
- Giscus — comments backed by GitHub Discussions.
- ESLint (flat) + Prettier +
astro check— lint/format/type-check; husky + lint-staged at pre-commit; Vitest for pure logic. - Wrangler + GitHub Actions — build & deploy.
i18n: Vietnamese at the root, English under /en/
Using Astro’s built-in i18n, Vietnamese at the root and English behind a prefix:
// astro.config.mjs
export default defineConfig({
site: 'https://gingatimo.com',
output: 'static',
i18n: {
defaultLocale: 'vi',
locales: ['vi', 'en'],
routing: { prefixDefaultLocale: false },
},
});
Each post is a pair of same-named files under vi/ and en/, linked by a translationKey in the frontmatter. Sharing one slug (same filename) lets @astrojs/sitemap generate correct hreflang alternates, and I add an x-default pointing at the Vietnamese version. The build fails on a duplicate translationKey within a locale or a slug mismatch across a pair — a small guardrail against content mistakes.
The language switcher only appears when a post actually has a translation.
Content & features
- Reading time computed automatically via a small remark plugin.
- Tags + per-tag pages (bilingual).
- Auto OG images: every post gets a 1200×630 social card at build. An important note for Vietnamese — the renderer needs a TTF font with full diacritics (I downloaded the Archivo variable font into
public/fonts/og/), otherwise Vietnamese titles render as tofu boxes. - Search via Pagefind: the index is generated in a
postbuildstep, and results are scoped to the page’s language (Pagefind splits its index by the<html>lang).
Deploy: GitHub Actions on vX.Y.Z tags
A release = create and push a tag. The workflow only fires on tags matching v*.*.*:
# .github/workflows/deploy.yml (trimmed)
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 }}
# …other PUBLIC_GISCUS_*
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 — keep them distinct:
- Secrets (sensitive):
CLOUDFLARE_API_TOKEN(scope it minimally to “Edit Cloudflare Workers”, restricted to the account) andCLOUDFLARE_ACCOUNT_ID. - Variables (public, baked into the HTML): the four
PUBLIC_GISCUS_*values. They end up visible in the page anyway, so they belong in Variables, not Secrets.
The vX.Y.Z tag is also injected into the footer (PUBLIC_SITE_VERSION = github.ref_name) so you can see which build is live. A separate CI workflow runs on pull requests: astro check, lint, format, build, and an internal link check.
Giscus: comments via GitHub Discussions
The one-time setup:
- Enable Discussions on the repo.
- Install the Giscus GitHub App on the repo.
- Create an Announcement-type category (so only maintainers/the app can open threads — no spam).
- Grab
repo-idandcategory-idfrom giscus.app, with mapping =pathname(each post URL → one thread; being bilingual, the VI and EN versions get separate threads). - Put the four values into GitHub Variables.
The Giscus component is lazy-loaded (only when scrolled into view, via IntersectionObserver) and theme-synced with the site’s light/dark toggle through postMessage.
The biggest gotcha: CSP blocks inline scripts
I set a strict Content-Security-Policy (no 'unsafe-inline'). After deploy, the theme toggle stopped working and the console reported a CSP error blocking an inline script.
The cause: Astro inlines processed `<script>` tags that have no import as an optimization. Under script-src 'self', that inline block gets blocked → the handler never attaches.
The correct fix: move the client JS into an external file in public/ and load it with `<script is:inline src="…">`. External is allowed by 'self', no need to loosen to 'unsafe-inline'. (Scripts that DO import get bundled to external /_astro/*.js, so they’re fine.)
A custom Giscus theme to match the brand
Giscus’s default theme is GitHub-flavored (blue). I wanted it to match the blog’s “sci-fi instrument” palette (warm near-black + an amber accent). Giscus supports a custom theme via a CSS URL.
The cleanest approach is to take Giscus’s own noborder_dark / noborder_light theme and just swap the two driving variables:
main {
--primary-default: 232, 182, 120; /* amber #e8b678 */
--bg-default: 10, 9, 8; /* near-black #0a0908 */
/* …everything else references those two */
}
Two easy-to-miss details:
- The CSS file must be served with a CORS header
Access-Control-Allow-Origin: *, because Giscus fetches it cross-origin from its iframe. - Giscus applies one theme at a time, so I host two files (dark + light) and swap the URL by mode when the user toggles the theme.
// public/giscus.js
const theme = () =>
document.documentElement.dataset.theme === 'light'
? 'https://gingatimo.com/giscus-theme-light.css'
: 'https://gingatimo.com/giscus-theme.css';
And a small gotcha: after editing a theme file, purge the Cloudflare cache for /giscus-theme*.css — Giscus fetches a fixed URL, so it can serve a stale cached copy.
Cloudflare configuration
This part lives outside the repo, in the dashboard:
- Custom domain: attach
gingatimo.comto the Worker (Workers → Domains & Routes → Custom Domain). Cloudflare provisions the SSL cert automatically. - DNS:
gingatimo.comis a proxied zone (orange cloud). Add awwwrecord (CNAME → apex, Proxied). - HTTPS: enable Always Use HTTPS to redirect http→https; make sure Universal SSL is Active.
- www → apex Redirect Rule: Rules → Redirect Rules, match hostname
www.gingatimo.com, use a Dynamic targetconcat("https://gingatimo.com", http.request.uri.path), status 301, preserve the query string. - Web Analytics: since the domain is proxied through Cloudflare, I enabled automatic injection (RUM Enable) — no token or manual snippet. The beacon component stays in the code but gated off (by env), to avoid double-counting.
Security: CSP + headers via _headers
It’s all declared in public/_headers (Cloudflare Static Assets reads this file):
/*
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' is for Pagefind’s WASM; giscus.app is allowlisted for script-src (client.js), style-src (the widget’s stylesheet) and frame-src (the iframe).
Gotchas worth remembering
- TypeScript 7 was too new: the environment resolved TS 7, but
@astrojs/checkandtypescript-eslintdon’t support it yet. I had to pin TypeScript to~6.0.xto keepastro check+ lint working. That’s the ecosystem lagging, not stale linters. - Astro inlines no-
importscripts → conflicts with a strict CSP (see above). - OG images need a TTF font with Vietnamese diacritics, or titles render as tofu.
- The
lycheelink checker in offline mode can’t resolve root-relative links (/about/) in local files — you need--root-dir "$(pwd)/dist". - Giscus theme caching — remember to purge after edits.
OGImageRoutein theastro-og-canvasversion I used returned an empty object — I switched to the coregenerateOpenGraphImageand exportedgetStaticPaths/GETmyself.
Wrapping up
The result: a static, bilingual, fast blog with search, brand-matched comments, and full SEO — and a release process that fits on one line:
git tag v1.0.0 && git push origin v1.0.0
GitHub Actions does the rest: verify → build → index search → wrangler deploy. The footer shows the exact version that shipped.
If you’re building your own static blog on Cloudflare Workers, I hope the gotchas above save you a few hours. Questions? Drop a comment below.
Tags: Astro · Cloudflare · DevOps · Meta