After years of running VPS boxes, installing Nginx, renewing SSL certificates and fixing things at midnight, I moved most of my static sites to Cloudflare Pages. The reason is simple: for this kind of site there is almost nothing left to administer.
The free tier is generous enough for most personal and small business projects: unlimited bandwidth, 500 builds a month, and an edge network that covers the globe.
Two ways to deploy
Git integration
The common approach. You connect a GitHub or GitLab repository, declare the build command and the output directory, and from then on every push builds and publishes automatically.
For an Astro project the config is usually:
- Build command:
pnpm build(ornpm run build) - Build output directory:
dist - Root directory: leave empty if the project sits at the repo root
The part I like most about this is preview deployments. Every pull request gets deployed to its own URL. Instead of describing a change in words during review, you send the client a link they can click. That alone has saved me a lot of back and forth.
The Wrangler CLI
When you need tighter control, or you deploy from your own CI pipeline:
pnpm add -D wrangler
npx wrangler pages deploy dist
I use this when I want to build somewhere else and only push the output, or when I need to deploy a folder that is not in the repo.
Pin the Node version
This was the first thing I tripped over. My machine runs Node 22 but Cloudflare Pages defaults to an older version, and the build failed with a message that made no sense.
The fix is to declare a NODE_VERSION environment variable in the project settings:
NODE_VERSION = 22.17.0
Or add an .nvmrc file at the repo root:
22.17.0
Astro projects usually declare this requirement in package.json already, and keeping the two in sync avoids a lot of trouble:
{
"engines": {
"node": ">=22.12.0"
}
}
Pick the right package manager
Cloudflare Pages detects the package manager from the lock file in your repo. If it finds pnpm-lock.yaml it uses pnpm, if it finds package-lock.json it uses npm.
Do not leave more than one lock file in the repo. I once lost a good chunk of time on a build failure caused by a leftover package-lock.json from before the switch to pnpm, which made Cloudflare install with npm and produce a dependency tree completely different from mine.
Custom domain and SSL
Add the domain under the Custom domains tab. If the domain already points its nameservers at Cloudflare, the DNS record is created for you, the SSL certificate is issued within minutes, and it renews forever on its own.
This is the thing I appreciate most after moving off a VPS. No more remembering certificate expiry dates, no more Let’s Encrypt renewal cron jobs failing silently.
If the domain lives at another registrar, you add a CNAME record pointing to the .pages.dev address Cloudflare gives you.
Redirects and headers
Two files placed in the public folder get picked up by Cloudflare after the build.
_redirects for redirects:
/blog/old-article /blog/new-article 301
/dich-vu/* /services/:splat 301
_headers for HTTP headers:
/*
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/assets/*
Cache-Control: public, max-age=31536000, immutable
That cache rule is worth noting: Astro names files in assets with a hash of their contents, so when the content changes the filename changes too. That means you can cache them forever without any risk of a visitor getting a stale copy.
A few more things that cost me time
Environment variables live in two separate environments. Cloudflare separates Production and Preview. A variable declared for Production does not automatically apply to preview builds, and the other way round. A build that works on main but breaks on pull requests is usually this.
Environment variables are only read at build time. On a pure static site every value is baked into the HTML when it builds. Changing a variable does nothing until you rebuild. And the more important consequence: never put secrets in the environment variables of a static site, because they end up sitting in plain sight in the page source.
The output directory has to match. Astro writes to dist, but if you change outDir in astro.config.mjs you have to update the Cloudflare setting too. Get this wrong and the build succeeds while the site comes up blank.
Cloudflare’s cache is not the browser cache. If you still see old content after a deploy, try purging the cache in the Cloudflare dashboard before you start suspecting the build.
When you need more than a static site
If you later need server-side work, say handling form submissions or calling an API with a secret key, Cloudflare Pages supports Functions. Drop a file into the functions folder and it becomes an endpoint running on the edge:
// functions/api/contact.js
export async function onRequestPost({ request, env }) {
const data = await request.formData();
// env.API_KEY only exists on the server, it never reaches the client
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
}
That is the natural upgrade path when a static site starts needing a bit of dynamic logic, without moving the whole project to different infrastructure.
This site runs on Cloudflare Pages.