Deploy a static site from GitHub to Cloudflare, free
Connect a GitHub repo to Cloudflare, build it on every push, and serve it from the edge on your own domain. No server, no bill, free HTTPS.
A portfolio, a blog or a docs site doesn't need a server. Every page can be generated once at build time and served as plain files. Running a VPS or a home machine just to hand out HTML means paying for uptime, patches and certificates you don't need.
Cloudflare can build the site straight from your GitHub repo and serve the result from its edge network. You
push to main, Cloudflare runs your build, and a minute later the new version is live on your domain.
git push main ──▶ Cloudflare build: npm ci ──▶ npm run build (static files in out/) ──▶ npx wrangler deploy
│
visitor ──https──▶ example.com ──▶ Cloudflare edge ──▶ your static files, served from the nearest location
What you get:
- No server to run. Nothing to patch, nothing to restart, nothing that goes down when your machine does.
- Deploys on every push. Other branches get their own preview URLs.
- A global CDN and free HTTPS. Static asset requests are free and unlimited on the free plan.
- One-click rollbacks. Every deploy is kept, and any of them can be put back live.
This guide goes from "I have a site in a GitHub repo" to "it's live on my domain and redeploys itself". The examples use Next.js. Any framework that can output a folder of static files works the same way.
What you need
- A site in a GitHub repository that can be built into static files (Next.js, Astro, Hugo, Vite, plain HTML, …).
- A free Cloudflare account.
- Optionally, a domain whose DNS is already on Cloudflare. If it isn't yet, Step 1 of the Cloudflare Tunnel guide covers moving the nameservers.
- Node.js on your own machine, to test the build before Cloudflare does.
Throughout this post, replace example.com with your domain and my-site with your project's name.
Step 1: Make the site static
Cloudflare will serve whatever ends up in your build folder, so the build has to produce plain files. For Next.js, that means the static export.
One line in next.config.ts is enough:
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "export" };
export default nextConfig;
A static export writes the site to out/. Two rules to know:
- Nothing can run per request. Route handlers,
cookies(),headers(), server actions andredirects()innext.config.tsdon't work. Every dynamic route needsgenerateStaticParams, so the build knows which pages to create. next/imagecan't optimise images. Either setimages: { unoptimized: true }or use plain<img>tags.
If the same repo must also build as a normal server somewhere else, turn the export on only when WORKERS_CI
(Workers) or CF_PAGES (Pages) is set. Cloudflare's builds set these automatically.
Other frameworks need nothing special. Just note their output folder: dist/ for Vite and Astro, public/ for
Hugo.
Step 2: Tell Cloudflare what to serve
New projects created from a repo are Workers that serve static assets. A small file at the repo root,
wrangler.jsonc, says which folder to upload and how to behave:
{
"name": "my-site",
"compatibility_date": "2026-04-01",
"assets": {
"directory": "./out",
"not_found_handling": "404-page",
"html_handling": "auto-trailing-slash"
}
}
namemust match the project name you pick in the dashboard. Otherwise the deploy creates a second project.directoryis your build output folder.not_found_handling: "404-page"serves your404.htmlwith a real 404 status for unknown paths.html_handlinglets/aboutserveabout.htmlwithout the extension showing in the URL.
Two more small files are worth adding:
-
public/_redirectsfor old URLs. The framework copies it intoout/, and Cloudflare reads it there:/old-page /new-page 301 /posts/:slug /blog/:slug 301 -
.node-versioncontaining your local major version, for example22or24. Without it, Cloudflare builds with its own default (Node 24 for Workers Builds), which may not match what you develop with.
Step 3: Test the build locally
Catch problems before Cloudflare does. Build the export, then serve it with Wrangler, which uses the same runtime as Cloudflare's edge:
npm run build
ls out/ # index.html, 404.html, _redirects, your pages ...
npx wrangler dev # serves out/ on http://localhost:8787
Then check the things the static setup is responsible for:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8787/ # 200
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8787/does-not-exist # 404
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:8787/old-page # 301 .../new-page
Commit wrangler.jsonc, public/_redirects and .node-version, and push. Add .wrangler/ to .gitignore,
because wrangler dev keeps local state there.
Step 4: Connect the GitHub repo
-
In the Cloudflare dashboard, go to Workers & Pages > Create application, and click Get started next to Import a repository.
-
Connect GitHub. When GitHub asks which repositories the Cloudflare app may access, choose Only select repositories and pick this one. It doesn't need the rest of your account.
-
Select the repo and fill in the settings:
Setting Value Project name my-site(the same asnameinwrangler.jsonc)Production branch mainBuild command npm run buildDeploy command npx wrangler deployEnvironment variables none needed -
Click Save and Deploy and open the build log. A healthy log shows
npm ci, then your build exporting the pages, then Wrangler uploading the assets. When it finishes, the site is live athttps://my-site.<account>.workers.dev.
From now on, every push to main triggers a new build and deploy.
Step 5: Put it on your own domain
A project's custom domain gets its own DNS record, of type Worker.
- DNS > Records: if
example.comorwwwalready points somewhere, delete thoseA,AAAAorCNAMErecords, or the next step fails. Leave email records (MX, and theTXTrecords for SPF, DKIM and DMARC) alone. - Workers & Pages > my-site > Settings > Domains & Routes > Add > Custom domain: add
example.com, then addwww.example.com. Cloudflare creates the DNS records and issues the certificates itself, usually within a minute or two. - Send
wwwto the bare domain. Go to Rules > Templates, pick Redirect from WWW to root, turn on Preserve query string, and deploy. The dashboard warns thatwwwmay not be proxied. You can ignore that, because the Worker record forwwwis proxied. Choose Ignore and deploy rule anyway.
Check it:
curl -sI https://example.com | grep -iE '^(HTTP|server)'
# HTTP/2 200
# server: cloudflare
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://www.example.com/blog?x=1'
# 301 https://example.com/blog?x=1
Step 6: Living with it
- Publishing: push to
main. The build takes a minute or two. - Previews: pushes to any other branch build a preview version with its own URL, which is handy for checking a post before it goes live. To keep previews private, put them behind Zero Trust > Access with your email as the only allowed identity.
- Rollbacks: open Deployments, find the last good version and promote it. This takes seconds and needs no new build.
- Skipping a build: put
[skip ci]in a commit message, for example for a README-only change. - Turn off
workers.devonce your domain works, under Settings > Domains & Routes, so visitors and search engines stay on your domain. Preview URLs are separate and keep working. - Security headers: add a
public/_headersfile. Cloudflare reads it the same way as_redirects.
What about Cloudflare Pages?
Pages is Cloudflare's older product for exactly this job, and it still works. Cloudflare now points new projects at Workers, which can serve static assets as well and add server code later in the same project. The setup differs in only a few places:
| Workers (this guide) | Pages | |
|---|---|---|
| Where | Workers & Pages > Create application > Import a repository | Workers & Pages > Create application > Pages tab |
| What to serve | assets.directory in wrangler.jsonc | "Build output directory" setting in the dashboard |
| Build detection variable | WORKERS_CI | CF_PAGES |
| Deploy command | npx wrangler deploy | none, Pages uploads the folder itself |
_redirects and _headers | Supported | Supported |
| Adding server code later | Same project | Pages Functions |
With a plain output: "export", the same repo builds on either one.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
next/font/google fails during the build | It downloads fonts at build time, which can fail in CI | Download the font files into the repo and use next/font/local |
| The log shows a different Node version from yours | No .node-version file | Add one with your major version |
| The deploy step tries to set up OpenNext and fails | wrangler.jsonc is missing, so Wrangler tries to auto-configure the project as a full Next.js app | Add wrangler.jsonc with an assets block |
The build passes but there's no out/ folder | The static export isn't on | Check output: "export" in next.config.ts |
| Old links return 404 | _redirects isn't in the output folder | Put it in public/ |
| Adding the custom domain says a record already exists | An old DNS record uses that name | Delete it, then add the domain again |
wrangler dev says the compatibility date is too new | The date is later than your Wrangler version supports | Lower compatibility_date or update Wrangler |
Recap
- Make the build produce static files (
output: "export"for Next.js). - Add
wrangler.jsoncfor the output folder and 404 page,_redirectsfor old URLs and.node-version. - Test locally with
npx wrangler dev. - Import the repo in Workers & Pages, with
npm run buildandnpx wrangler deploy. - Add your custom domain and the www-to-root redirect.
Your site now deploys itself on every push, and serves from Cloudflare's edge with nothing for you to keep running.
Connected
shares a topic with this postFree email on your own domain with Cloudflare Email Routing and Gmail
Receive mail at you@example.com in your existing Gmail inbox for free, and the options for replying from that address.
Streamlit Portfolio Template
A portfolio and resume website written entirely in Python. Edit one data file, run one command, and you have a dark-themed, animated personal site with a working contact form.
Host a website from your own machine with Cloudflare Tunnel
Move your domain to Cloudflare, create a tunnel, and serve apps running on your own hardware without opening a single port.