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:

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

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

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:

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:

  1. Enable Discussions on the repo.
  2. Install the Giscus GitHub App on the repo.
  3. Create an Announcement-type category (so only maintainers/the app can open threads — no spam).
  4. Grab repo-id and category-id from giscus.app, with mapping = pathname (each post URL → one thread; being bilingual, the VI and EN versions get separate threads).
  5. 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:

// 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:

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

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