Skip to content
On this page

After the domain moved, I put the static blog on Pages

By yilihjyPublished 7 min read

The previous post was about moving the domain: from Namecheap to Cloudflare, $18.68 a year. With the domain moved, the next step was hosting.

This blog is a static site generated by Astro: no login, no database, no code that runs per request; the build output is HTML, CSS, JS and images.

Cloudflare’s Pages and Workers can both host a site. In the end I used Pages.

The path of a static site from GitHub Actions to Cloudflare Pages, then to the edge and visitors; the Worker node has no code to run.
A static site goes from Actions to Pages, then to the edge; the Worker node has no code to run.

Why Pages, not Workers

The difference between Pages and Workers is whether there is code to run after a request comes in. Pages is for static sites; Workers are for code that runs per request. This site has no such code.

The first entry on the create page is Workers. This is a static site; Workers are not needed here.

Worker Static Assets can host a build output too. The cost is an extra layer of resource configuration, plus decisions about redirects, response headers, previews, rollbacks and custom domains; Pages makes all of that the default.

Pages Worker
Fit Static sites, front ends Code that runs per request
Deployment GitHub Actions GitHub Actions
Preview One preview URL per deployment Version previews
Rollback Pick a previous deployment in the console Version rollback
Redirects, headers Pages conventions Implement yourself
Free tier Static requests and bandwidth unlimited 100k requests/day
Complexity Low High

When comments, login, a dynamic API or an auth entry for the CMS are actually needed, add a Worker separately. The blog’s static pages can stay on Pages.

The first deployment

The repository is a pnpm workspace; the blog lives in apps/blog, and the build output in apps/blog/dist. Deployment runs in GitHub Actions: when the main branch gets a push, it installs dependencies, runs the blog’s checks, and uploads dist to Pages.

The checks are not just a build. They include artifact assertions, size budgets and content validation; they run again before deployment, and Pages only receives a dist that has passed them. When a deployment fails, the problem is in the build or an assertion, not in a “file that was only half uploaded”.

Actions needs two credentials: an API token with only Pages permissions, and an account id. They live in the repository’s secrets, not in the code.

In Cloudflare there is one Pages project, and the production branch is the main branch. The project was first created through Direct Upload: uploading dist once in the console created it; after that every deployment is overwritten by Actions. The console upload was only used to create the project.

When a deployment fails or needs a rollback, pick a previous successful deployment in the Pages console. Rollback does not depend on CI or a rebuild; every deployment is a complete artifact.

Cloudflare’s Git integration is not used: it would consume the account’s 500 builds/month quota, and the build is already done in Actions, so having Cloudflare build it again would be duplicate work; the Actions checks also cannot be skipped.

Pages gives every project a default domain of the form *.pages.dev, pointing to the production deployment. Each deployment also gets a preview URL of the form <hash>.*.pages.dev: * is the project name and <hash> identifies that deployment. The preview URL and the custom domain are two different things; the former is for looking at this build before merging, the latter is the real address.

Current architecture

The current architecture: visitors reach Pages through the Cloudflare edge; the custom domain goes straight to Pages; the default domain goes through Access first; Actions builds and uploads.

flowchart TD
  accTitle: Current site architecture
  accDescr: Visitors reach the site through the Cloudflare edge; the custom domain goes straight to Pages; the default domain goes through Access; GitHub Actions builds and uploads the static output.
  visitor["Visitor"] -->|HTTPS| edge["Cloudflare edge\nHSTS / nosniff"]
  edge -->|Custom domain apex / www| pages["Cloudflare Pages\nstatic output"]
  edge -->|Default domain *.pages.dev| access["Cloudflare Access\nlogin"]
  access -->|Allowed| pages
  actions["GitHub Actions\nmain push → build + checks"] -->|Upload dist| pages
  pages -->|Response| edge
  edge -->|www 301 to apex| visitor

The bill

The marginal cost of this setup is close to zero. The free tiers are worth looking at separately.

Item Free tier What I use
Pages static requests, bandwidth Unlimited All of it
Pages Git builds 500/month None (Direct Upload)
GitHub Actions 2,000 minutes/month Well under the cap
Cloudflare Access 50 seats 1
DNS, SSL, HSTS Free All of it
Worker 100k requests/day None
Domain Annual fee Once a year

Pages does not charge for static requests or bandwidth, which is another reason to pick it over Workers. Workers’ free tier is counted by request; a static site on Workers would spend the 100k/day allowance on ordinary traffic; Pages does not have that problem.

The 2,000 Actions minutes are the private-repository allowance. This repository runs checks and deployment only on the main branch and PRs; the development branch does not run them. Actual usage is well under the cap.

Access’s 50 seats are users, not visits. One seat covers the login on the default domain; blocked visitors do not take a seat.

The only fixed cost is the annual domain fee, which the previous post worked out. Everything else stays inside the free tier.

apex and HSTS

Two terms first. The apex is the bare domain, example.com, without www; a custom domain is a domain bound to a Pages project, not the default *.pages.dev. An apex custom domain is the bare domain bound to Pages.

The apex has a DNS record pointing to Pages; Cloudflare does the CNAME flattening; in Pages the apex is the Custom domain. www has a DNS record pointing to the apex, plus a 301 to the apex; path and query string are preserved.

SSL is Full (strict), and Always Use HTTPS is on.

HSTS is the HTTP Strict Transport Security response header. Once a browser sees it, the browser forces HTTPS for the max-age, instead of trying HTTP first; it defends against link hijacking and SSL stripping. The first visit still depends on the 301; only preload writes the domain into the browser’s built-in list. includeSubDomains extends the rule to subdomains; preload is harder and harder to undo.

HSTS is set to 30 days, without includeSubDomains and without preload. Keep it short first, confirm no subdomain is still on HTTP, then consider extending it. The cost of preload is long-term: once the domain is in the browser list, removal waits for a version update; a personal blog does not need that.

Access and security

Pages’ default domain is the project entry point and the preview entry point. I put Access on the default domain, requiring a login; the real custom domain stays public.

Only the default domain is protected because it and the custom domain are managed separately in Zero Trust. The default domain is the deployment entry; the custom domain is the address readers visit. Closing the default domain keeps previews and unmerged content from leaking; the custom domain stays public.

Access’s free tier is 50 users. Here it is one: my own email. Blocked visitors do not take a seat.

The HSTS trade-off was covered in the previous section: short max-age first; reversibility matters more than maxing it out in one go.

X-Content-Type-Options: nosniff is on too. It makes the browser trust the response’s Content-Type instead of guessing the file type; there is no downside for a static site.

The certificate is TLS 1.3, valid for both apex and www.

Verification

After deployment I checked DNS first: apex and www both resolve to Cloudflare, and the NS records are right. Then HTTP: apex is 200, www is a 301 to apex, the default domain is a 302 to the Access login, http:// is a 301 to HTTPS, and a missing path is a 404. Finally the security headers: strict-transport-security and x-content-type-options are both present.

The default domain is only a deployment and preview entry; it does not appear in any public link on the site.

Boundaries

The domain handles the address, Pages handles the static output, the build and checks are left to Actions, and access to the default domain is left to Access.

This combination is enough for a static site. When dynamic capabilities are actually needed, add a Worker; a static site does not need to carry a runtime for hypothetical future requirements.

Cost-wise, apart from the annual domain fee, everything stays inside the free tier. The real time goes into building, checking and maintenance, not the bill.

The next step, if this blog really needs comments, login or a dynamic API, is to consider Workers. For now it does not.

References

Note: the numbers in this post reflect October 2026.