Todos los artículos

How We Build and Deploy grisu.co: Astro on Cloudflare Workers

Por Grisú Studio ·

How We Build and Deploy grisu.co: Astro on Cloudflare Workers

grisu.co is a small website with an unusual job. Besides the home page and this blog, it hosts a marketing page, a privacy policy and an account-deletion page for every app we make, about 60 of them, in five languages. Those URLs are not just for visitors. The App Store and Google Play listings point at them, and app reviewers open them. If a privacy policy URL returns a 404 while an app is in review, the app can be rejected for a reason that has nothing to do with the app.

That one fact shaped every decision below: the site has to be boring, fast and impossible to get out of sync with the repository. Here is how it is built and deployed today.

The short version

Piece What we use
Framework Astro, output: "static"
Styling Tailwind CSS 4, through its Vite plugin
Content Astro content collections, one Markdown file per app
Languages English, Spanish, Portuguese (Brazil), French, German
Hosting Cloudflare Workers static assets
Deploys Cloudflare Workers Builds, on every merge to master
Package manager pnpm

There is no server code, no database and no CMS. Every page is an HTML file produced at build time.

Why a static site

Nothing on grisu.co needs to be computed per visitor. An app page, a privacy policy and a blog post are the same for everyone, so rendering them once at build time and serving files is the simplest thing that works. It also means there is no server to patch, nothing that can fail at request time, and no visitor data held anywhere, which matters for a studio whose apps keep data on the device.

One Markdown file per app

Each app lives in src/content/apps/<slug>.md. The frontmatter holds the store name, tagline, description, icon, screenshots, store links, category, features, the full privacy policy and any translations. A schema in src/content.config.ts validates all of it with Zod, so a typo in a field name or a store title longer than 30 characters fails the build instead of shipping.

From that single file the build generates:

  • /apps/<slug>/, the marketing page the store listing links to
  • /privacy/<slug>/, the privacy policy
  • /delete-account/<slug>/, the account and data deletion page Google Play asks for

Adding an app to the site is adding one file.

Every page in five languages

Pages live under src/pages/[...lang]/. The [...lang] rest parameter is undefined for English, which produces un-prefixed URLs (/apps/), and es, pt, fr or de for the others (/es/apps/). One LOCALES constant in src/i18n/config.ts drives the routes, the language switcher and the hreflang tags. Portuguese is declared as pt-BR, because that is the Portuguese it is written in.

Interface strings live in one JSON file per language. A small script, scripts/check-i18n.mjs, runs before every build and fails it if any locale is missing a key, has an extra one, or drops a {placeholder}. It exists because a renamed key once made a page print the raw key name to Spanish readers, and nothing else noticed.

Where a translation does not exist yet, such as an app description or a privacy policy that has not been translated, the page falls back to English and says so in a visible banner instead of passing English off as the translation.

The full build command is:

node scripts/check-i18n.mjs && astro check && astro build

astro check type-checks every .astro file, and astro build writes about a thousand HTML pages to dist/.

Hosting on Cloudflare Workers static assets

The site is served by a Cloudflare Worker with no Worker script at all. The assets block in wrangler.jsonc tells wrangler deploy to upload dist/ and serve it from Cloudflare’s edge:

{
  "name": "website",
  "compatibility_date": "2026-09-15",
  "assets": {
    "directory": "./dist",
    "html_handling": "auto-trailing-slash",
    "not_found_handling": "404-page"
  }
}

Cloudflare Workers Builds is connected to the GitHub repository. On every merge to master it runs:

pnpm run build
npx wrangler deploy

That takes about two minutes, and there is no other deploy step. A page that is in master is live.

Before this, the site was uploaded by hand over FTP to shared hosting, and that is exactly how it went wrong. In September 2026 a sweep of every privacy policy URL found four of them returning 404 in production while they were in the repository and in the local dist/. Writing a page and publishing it were two separate acts, and only the first left a trace in git. Deploying from master removes the second act.

wrangler.jsonc is also required: without it, the Workers Builds deploy step fails with a missing entry point.

Trailing slashes

Astro writes directory-style pages, so /apps/quadrille/ is really dist/apps/quadrille/index.html. With html_handling: "auto-trailing-slash", Cloudflare serves that file for /apps/quadrille/ and redirects /apps/quadrille to it. Both spellings that might appear in a store listing or inside an app resolve to the same page. Astro’s own trailingSlash: "always" makes the dev server reject the slashless form, so a wrong internal link shows up while we are building rather than after deploy.

The 404 page

not_found_handling: "404-page" makes Cloudflare serve dist/404.html with a real 404 status for any unknown path. The page lives at src/pages/404.astro, at the root and deliberately outside [...lang]: only a root-level 404 page is emitted as 404.html. The trade-off is that the 404 page is always in English, because a static host cannot know the visitor’s language at the moment it serves a miss.

Cache headers with _headers

Workers ignore Apache’s .htaccess, so caching rules moved to public/_headers, which Astro copies into dist/ and Cloudflare applies:

/*
  Cache-Control: public, max-age=0, must-revalidate
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

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

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

The reasoning:

  • HTML is always revalidated. A cached page is cheap to check and expensive to get wrong. A stale 404 on a privacy policy URL once cost us an app review, so every HTML page and text file is checked against the origin on every request.
  • /_astro/* is cached for a year. Astro fingerprints the CSS, JavaScript and optimised images it emits there, so a changed file always gets a new URL and the old one can be cached forever.
  • Self-hosted fonts are cached for a year for the same reason: they never change under the same name.
  • The ! lines remove the broad Cache-Control before the specific one is added, so the two rules never both apply.

Two related details: a _redirects file on Workers only accepts relative targets, so the www to apex redirect is a Redirect Rule on the Cloudflare zone instead. And DNS for grisu.co is on Cloudflare, alongside the site.

Sitemap and robots.txt

@astrojs/sitemap writes sitemap-index.xml, and public/robots.txt points crawlers to it. Two settings do most of the work:

  • site: "https://grisu.co" in astro.config.mjs. This one string is the base of every canonical URL, hreflang alternate and og:url the build emits. When it once held the wrong domain, every page told search engines it was a copy of a site that was not the site.
  • The sitemap’s i18n option maps each locale to its language tag (pt to pt-BR), so each URL in the sitemap lists its translations as alternates.

The sitemap also reads each built page’s <head> and leaves out anything that is noindex or canonicalises to another URL. An untranslated copy of a page, which points its canonical at the English original, never appears as a separate entry.

Images

App icons, screenshots and blog covers live in src/assets/ and go through Astro’s image pipeline with Sharp, which resizes them and emits modern formats under /_astro/. The few files in public/, like the 404 illustration, are served as they are, so we size them by hand. The 404 image used to be a 924 KB PNG and is now a 14 KB WebP at the size it is displayed.

Checking production, not the build

A green build proves the files exist in dist/. It does not prove a reviewer can open them. Before every store submission we run a script that fetches every app’s declared marketing, privacy and deletion URL from production and fails on anything that is not a 200. That check is the reason we found the four missing pages, and the reason they cannot quietly go missing again.

Summary

  • Build static HTML with Astro, and make the content schema strict enough that mistakes fail the build.
  • Generate every language from one set of templates, and fail the build if the translation files drift apart.
  • Serve dist/ from Cloudflare Workers static assets with auto-trailing-slash and a real 404 page.
  • Revalidate HTML on every request and cache fingerprinted assets for a year.
  • Deploy on merge to master, so the live site and the repository cannot disagree.

It is not a complicated setup, and that is the point. The site exists so that every app’s store listing has pages that always load, and for that, boring is the right design.