TL;DR You have a static site and a domain. Cloudflare Pages serves it, Terraform sets up the hosting from a few files in your repo, R2 keeps Terraform’s memory, and GitHub Actions runs it all when you push. The bill is zero. The example repo walks through the setup one commit at a time.
I wrote the first version of this post in 2021. Since 2019 I had hosted a few sites for friends and family on AWS: S3 for files, CloudFront in front, Cloudflare for DNS only. Route53 charged half a dollar per domain per month, more than S3 and CloudFront together for sites that small. That post was about shaving that fee.
Cloudflare Pages left beta in 2021, with a free plan. I could have moved the sites then, but Terraform state had nowhere to go on Cloudflare. R2 arrived a year later and could not lock state until 2024, and the S3 bill for a few kilobytes of state was cents. So the state stayed on S3.
Terraform 1.10 shipped S3 native locking in late 2024, and R2 honours the conditional write it relies on. That removed the last reason to keep an AWS account. The sites moved to Pages, and this week the state moved to R2. This post describes the setup I use now for this blog and those same sites.
Who this is for Link to heading
You built a site with Vite, Astro, Hugo, React, Vue, Next.js or plain HTML. You have a domain, or ten dollars to buy one. You want the site live on that domain with HTTPS, and you do not want to pay for a server or click through a dashboard from memory the next time you set one up.
You do not need to know Terraform, R2 or GitHub Actions. Each gets a short introduction below, with a link to the official docs when you want more.
The idea Link to heading
Your git repository holds three things: the site files, a description of the hosting, and a list of steps to run on push.
Push to main, and a few minutes later the site is live on your domain.
Change the site, push again.
Change the hosting, push again.
Nothing to remember, nothing to click.
Four tools make that work.
Cloudflare Pages hosts static files on Cloudflare’s network with HTTPS and a <name>.pages.dev address.
You attach your own domain to it.
The free plan has no bandwidth cap. Pages docs.
Terraform turns infrastructure into text files.
You write down what you want, a Pages project and a DNS record, and run terraform apply.
Terraform calls the Cloudflare API and creates them.
Edit the file and run again, and it changes only what differs. What is Terraform.
I first used Terraform in 2016, and since then declarative files and infrastructure have been the same thing to me.
Today I skip the cloud provider’s API docs and read the Terraform resource page instead.
R2 is Cloudflare’s file storage. Terraform keeps a small file called state that records what it created. Run Terraform only from your laptop and the file can stay there. GitHub Actions starts on a fresh machine every run, so the file has to live somewhere shared. A shared file brings a second problem: two runs writing it at once corrupt it, so the storage must also support a lock. R2 does both. R2 docs and Terraform state.
GitHub Actions runs commands on GitHub’s servers when you push. One YAML file in the repo lists the steps. Understand GitHub Actions.
What Terraform manages Link to heading
Two things. The Pages project with your domain attached, and one CNAME record that points the domain at the project. The whole description fits in a screen. This is the project.
# Pages project. Cloudflare creates it on the first apply.
resource "cloudflare_pages_project" "site" {
account_id = var.cloudflare_account_id
name = var.project_name
production_branch = "main"
}
# Your domain on that project. Cloudflare issues the certificate.
resource "cloudflare_pages_domain" "site" {
account_id = var.cloudflare_account_id
project_name = cloudflare_pages_project.site.name
name = var.site_name
}Each resource block is one thing Cloudflare will create.
The var. values come from a small file you edit once, so the same Terraform works for any site.
The DNS record looks the same and lives in dns.tf.
State goes to R2 through Terraform’s S3 backend, because R2 speaks the S3 protocol. The connection details are eleven lines in backend.config, copied from Cloudflare’s guide. You change one line, the bucket name.
Setting up remote state is one command.
Moving it later, S3 to R2 in my case, made me nervous the first two times.
Lose the file, say by deleting the bucket before taking a backup, or corrupt it, and your sites keep running but Terraform forgets what it created.
The next apply tries to create everything again and fails on names that already exist.
The fix is to tell Terraform about each existing resource with an import block, which gets tedious with many resources.
Back up the state before any move.
What runs on push Link to heading
The workflow runs on every push, on any branch.
- Check formatting and validate the Terraform files.
- Connect to the state in R2.
- Plan: compare the files with what exists, print what would change.
On main it continues.
- Apply that plan.
- Upload
site/to the Pages project with Wrangler, Cloudflare’s command line tool.
So a branch push is a dry run.
Open a pull request, read the plan in the job log, merge, and main does the same thing for real.
Tokens never sit in the YAML.
They come from repository secrets and reach Terraform as environment variables.
The file is deploy.yml.
Plan and apply are separate steps for two reasons.
First, you gate apply on main and read the plan as a diff of your infrastructure.
The plan earns its keep once you use data blocks.
Instead of pasting an id or a name into a resource, you look it up from the live API, and a wrong lookup fails at plan time instead of after apply.
Static values have bitten me more than once, an id copied from a dashboard that someone later recreated.
Second, apply destroys things as a side effect of edits.
Manage your DNS record with Terraform, and one stray keystroke in the name field turns xyz.example.com into jxyz.example.com.
Terraform deletes the old record, creates the new one, and reports success.
Your site is down until you notice, or until a smoke test notices for you.
A j from vim habit, typed into VS Code, has done this to me more than once.
Reading the plan catches it, and a lifecycle block with prevent_destroy on the record blocks it outright.
Setting it up Link to heading
The example repo is the tutorial.
Each commit is one step and adds its own section to the README, so git log --reverse reads top to bottom.
- Prerequisites: email, GitHub account, a card, Node.js.
- Cloudflare account.
- Domain: buy at Cloudflare Registrar, or move an existing one.
- Turn on R2, create the state bucket.
- Two API tokens, each scoped to one bucket or one zone.
- Site files.
- Terraform files: edit two values.
- Workflow file: edit one value.
- GitHub repo, five secrets, push, watch it go live.
- Deploying from your own machine, same commands the workflow runs. Optional.
- Tearing it all down.
Steps 1 to 5 are dashboard work and take about half an hour the first time. Steps 6 to 9 are a copy, two edits and a push. Terraform itself never needs to run on your machine.
The AWS and Cloudflare combination was rough in the early days, ACM most of all: validation records created by hand, a trailing dot mismatch between the two providers, CNAME flattening at the apex, and state conflicts when a wildcard and a named domain shared one certificate. Once an apply went through it stayed quiet. Newer provider versions fixed each of these, and the Cloudflare-only setup above never touches ACM. I still point people at Terraform or OpenTofu for any infrastructure they want to keep.
GitHub Actions on top makes the loop invisible. I fix a typo from my phone, commit, and the site has changed before I open it to check. Any CI does this. I came from Jenkins and have run the same shape on GitLab, Drone and CircleCI. I stay on GitHub Actions because a runner step and a container step sit side by side in one job, and because the YAML had no anchors or references for years. Every pipeline reads the same way, instead of carrying the fingerprints of whoever wrote it.
Cost and limits Link to heading
| Item | 2021 | Now |
|---|---|---|
| Hosting | S3 and CloudFront, cents | Pages, free |
| DNS | Cloudflare, free | Cloudflare, free |
| Certificate | ACM, free | Pages, free |
| Terraform state | S3, cents | R2, free |
| Accounts | AWS and Cloudflare | Cloudflare |
The domain is the only line on the bill. Free has limits, and these are the ones this setup can touch. Values checked on 16 September 2026 against the linked pages.
| Service | Free plan limit | Source |
|---|---|---|
| Cloudflare Pages | 500 builds a month, 1 at a time. 20,000 files and 25 MiB per file per upload. 100 projects, 100 custom domains each. No bandwidth or request cap listed. | Pages limits |
| Cloudflare R2 | 10 GB stored, 1 million writes and 10 million reads a month. No egress charge. Card required to enable. | R2 pricing |
| GitHub Actions | Free on public repos. Private repos: 2,000 minutes and 500 MB of artifacts a month. | Actions billing |
| Cloudflare API | 1,200 requests per five minutes per user. One Terraform run makes a handful. | API limits |
A blog that deploys a few times a week sits far inside all of these. The 500 builds line counts Cloudflare’s own build system; the workflow here uploads finished files, and the docs do not say whether that counts. At a few pushes a week it makes no difference.
Summary Link to heading
One repository holds the site, the Terraform that describes its hosting, and the workflow that ships both.
Cloudflare Pages serves the files, one CNAME points your domain at it, and R2 holds the Terraform state with a lock.
Every push shows a plan; a push to main applies it and uploads the site.
Setup is a domain on Cloudflare, one bucket, two tokens, five GitHub secrets, and two edited values. After that the dashboard stays closed. The bill is the domain. The example repo records every step as a commit, and this site and a few others run on the same files.
Where to go next Link to heading
Preview URLs.
Pass --branch=${{ github.ref_name }} to the upload step on branch pushes and each branch gets its own <branch>.<project>.pages.dev address. Direct upload docs.
More of the zone in Terraform.
Redirects, email routing, firewall rules are each another resource block in the same folder. Cloudflare’s Terraform guide.
One thing a static site cannot do on its own is a contact form. Mine ran on an AWS Lambda and now runs on a Cloudflare Worker. Both are free at this volume, and one cloud is one fewer login to keep alive. That move gets its own post.