TL;DR Je hebt een statische site en een domein. Cloudflare Pages serveert hem, Terraform zet de hosting op vanuit een paar bestanden in je repo, R2 bewaart het geheugen van Terraform en GitHub Actions draait alles zodra je pusht. De rekening is nul. De voorbeeldrepo loopt de opzet commit voor commit door.
De eerste versie van dit stuk schreef ik in 2021. Sinds 2019 hostte ik een paar sites voor vrienden en familie op AWS: S3 voor de bestanden, CloudFront ervoor, Cloudflare alleen voor DNS. Route53 rekende een halve dollar per domein per maand, meer dan S3 en CloudFront samen voor sites van dat formaat. Dat stuk ging over het schrappen van die kosten.
Cloudflare Pages kwam in 2021 uit bèta, met een gratis plan. Ik had de sites toen al kunnen verhuizen, maar de Terraform-state kon nergens heen op Cloudflare. R2 kwam een jaar later en kon tot 2024 geen state vergrendelen, en de S3-rekening voor een paar kilobyte state was centen. Dus bleef de state op S3.
Terraform 1.10 bracht eind 2024 native S3-vergrendeling, en R2 ondersteunt de conditionele write waar die op leunt. Daarmee verviel de laatste reden om een AWS-account aan te houden. De sites verhuisden naar Pages, en deze week verhuisde de state naar R2. Dit stuk beschrijft de opzet die ik nu gebruik voor dit blog en diezelfde sites.
Voor wie dit is Link to heading
Je hebt een site gebouwd met Vite, Astro, Hugo, React, Vue, Next.js of kale HTML. Je hebt een domein, of tien euro om er een te kopen. Je wilt de site live op dat domein met HTTPS, en je wilt geen server betalen of de volgende keer uit je hoofd door een dashboard klikken.
Je hoeft Terraform, R2 of GitHub Actions niet te kennen. Elk krijgt hieronder een korte introductie, met een link naar de officiële documentatie als je meer wilt.
Het idee Link to heading
Je git-repository bevat drie dingen: de sitebestanden, een beschrijving van de hosting, en een lijst stappen die bij een push draaien.
Push naar main, en een paar minuten later staat de site live op je domein.
Wijzig de site, push opnieuw.
Wijzig de hosting, push opnieuw.
Niets om te onthouden, niets om aan te klikken.
Vier tools maken dat mogelijk.
Cloudflare Pages host statische bestanden op het netwerk van Cloudflare, met HTTPS en een <naam>.pages.dev-adres.
Je koppelt er je eigen domein aan.
Het gratis plan heeft geen bandbreedtelimiet. Pages-documentatie.
Terraform maakt van infrastructuur tekstbestanden.
Je schrijft op wat je wilt, een Pages-project en een DNS-record, en draait terraform apply.
Terraform roept de Cloudflare-API aan en maakt ze aan.
Pas het bestand aan, draai opnieuw, en alleen het verschil verandert. Wat is Terraform.
R2 is de bestandsopslag van Cloudflare. Terraform houdt een klein bestand bij, de state, waarin staat wat het heeft aangemaakt. Draai je Terraform alleen vanaf je laptop, dan kan dat bestand daar blijven. GitHub Actions begint elke run op een schone machine, dus moet het bestand ergens gedeeld staan. Een gedeeld bestand brengt een tweede probleem: twee runs die het tegelijk schrijven maken het kapot, dus moet de opslag ook een lock ondersteunen. R2 doet beide. R2-documentatie en Terraform state.
GitHub Actions draait commando’s op de servers van GitHub zodra je pusht. Eén YAML-bestand in de repo somt de stappen op. GitHub Actions begrijpen.
Wat Terraform beheert Link to heading
Twee dingen. Het Pages-project met je domein eraan gekoppeld, en één CNAME-record dat het domein naar het project wijst. De hele beschrijving past op één scherm. Dit is het 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
}Elk resource-blok is één ding dat Cloudflare aanmaakt.
De var.-waarden komen uit een klein bestand dat je één keer invult, zodat dezelfde Terraform voor elke site werkt.
Het DNS-record ziet er hetzelfde uit en staat in dns.tf.
De state gaat naar R2 via de S3-backend van Terraform, want R2 spreekt het S3-protocol. De verbindingsgegevens zijn elf regels in backend.config, overgenomen uit de handleiding van Cloudflare. Je wijzigt één regel, de bucketnaam.
Remote state opzetten is één commando.
Hem later verhuizen, in mijn geval van S3 naar R2, maakte me de eerste twee keer nerveus.
Raak je het bestand kwijt, bijvoorbeeld door de bucket te verwijderen voordat je een back-up maakt, of raakt het beschadigd, dan blijven je sites draaien maar vergeet Terraform wat het heeft aangemaakt.
De volgende apply probeert alles opnieuw aan te maken en strandt op namen die al bestaan.
De oplossing is Terraform elke bestaande resource aanwijzen met een import-blok, wat bij veel resources een taai karwei wordt.
Maak een back-up van de state voor elke verhuizing.
Wat er draait bij een push Link to heading
De workflow draait bij elke push, op elke branch.
- Controleer de opmaak en valideer de Terraform-bestanden.
- Maak verbinding met de state in R2.
- Plan: vergelijk de bestanden met wat er bestaat en druk af wat er zou veranderen.
Op main gaat hij verder.
- Voer dat plan uit.
- Upload
site/naar het Pages-project met Wrangler, het commandoregelprogramma van Cloudflare.
Een push naar een branch is dus een proefdraai.
Open een pull request, lees het plan in het joblog, merge, en main doet hetzelfde in het echt.
Tokens staan nooit in de YAML.
Ze komen uit repository secrets en bereiken Terraform als omgevingsvariabelen.
Het bestand is deploy.yml.
Plan en apply zijn om twee redenen aparte stappen.
Ten eerste beperk je apply tot main en lees je het plan als een diff van je infrastructuur.
Het plan verdient zichzelf terug zodra je data-blokken gebruikt.
In plaats van een id of naam in een resource te plakken zoek je hem op in de live API, en een verkeerde opzoeking faalt bij het plan in plaats van na de apply.
Statische waarden hebben me meer dan eens gebeten, een id gekopieerd uit een dashboard dat iemand later opnieuw aanmaakte.
Ten tweede vernietigt apply dingen als bijwerking van bewerkingen.
Beheer je DNS-record met Terraform, en één verdwaalde toetsaanslag in het naamveld maakt van xyz.example.com jxyz.example.com.
Terraform verwijdert het oude record, maakt het nieuwe aan en meldt succes.
Je site is onbereikbaar tot je het merkt, of tot een smoke test het voor je merkt.
Een j uit vim-gewoonte, getypt in VS Code, heeft me dit meer dan eens geflikt.
Het plan lezen vangt het af, en een lifecycle-blok met prevent_destroy op het record blokkeert het helemaal.
De opzet Link to heading
De voorbeeldrepo is de handleiding.
Elke commit is één stap en voegt zijn eigen sectie aan de README toe, zodat git log --reverse van boven naar beneden leest.
- Vereisten: e-mail, GitHub-account, een betaalkaart, Node.js.
- Cloudflare-account.
- Domein: koop er een bij Cloudflare Registrar, of verhuis een bestaand domein.
- Zet R2 aan, maak de state-bucket.
- Twee API-tokens, elk beperkt tot één bucket of één zone.
- Sitebestanden.
- Terraform-bestanden: twee waarden aanpassen.
- Workflowbestand: één waarde aanpassen.
- GitHub-repo, vijf secrets, push, kijk hoe hij live gaat.
- Deployen vanaf je eigen machine, dezelfde commando’s als de workflow. Optioneel.
- Alles weer afbreken.
Stap 1 tot en met 5 zijn dashboardwerk en kosten de eerste keer ongeveer een half uur. Stap 6 tot en met 9 zijn een kopie, twee bewerkingen en een push. Terraform zelf hoeft nooit op je machine te draaien.
De combinatie AWS en Cloudflare was in het begin ruw, ACM bovenal: validatierecords met de hand aangemaakt, een verschil in afsluitende punt tussen de twee providers, CNAME-flattening op de apex, en stateconflicten wanneer een wildcard en een benoemd domein één certificaat deelden. Zodra een apply erdoor was bleef het rustig. Nieuwere providerversies losten elk van deze punten op, en de opzet hierboven met alleen Cloudflare raakt ACM nooit aan. Ik wijs mensen nog steeds op Terraform of OpenTofu voor elke infrastructuur die ze willen houden.
GitHub Actions erbovenop maakt de lus onzichtbaar. Ik verbeter een typfout vanaf mijn telefoon, commit, en de site is al veranderd voordat ik hem open om te controleren. Elke CI kan dit. Ik kom van Jenkins en heb dezelfde vorm gedraaid op GitLab, Drone en CircleCI. Ik blijf bij GitHub Actions omdat een runner-stap en een container-stap naast elkaar in één job staan, en omdat de YAML jarenlang geen anchors of references had. Elke pipeline leest hetzelfde, in plaats van de vingerafdrukken te dragen van wie hem schreef.
Kosten en limieten Link to heading
| Onderdeel | 2021 | Nu |
|---|---|---|
| Hosting | S3 en CloudFront, centen | Pages, gratis |
| DNS | Cloudflare, gratis | Cloudflare, gratis |
| Certificaat | ACM, gratis | Pages, gratis |
| Terraform-state | S3, centen | R2, gratis |
| Accounts | AWS en Cloudflare | Cloudflare |
Het domein is de enige regel op de rekening. Gratis heeft limieten, en dit zijn de limieten die deze opzet kan raken. Waarden gecontroleerd op 16 september 2026 tegen de gelinkte pagina’s.
| Dienst | Limiet gratis plan | Bron |
|---|---|---|
| Cloudflare Pages | 500 builds per maand, 1 tegelijk. 20.000 bestanden en 25 MiB per bestand per upload. 100 projecten, 100 eigen domeinen per project. Geen limiet op bandbreedte of verzoeken vermeld. | Pages-limieten |
| Cloudflare R2 | 10 GB opslag, 1 miljoen schrijf- en 10 miljoen leesbewerkingen per maand. Geen kosten voor uitgaand verkeer. Betaalkaart nodig om in te schakelen. | R2-prijzen |
| GitHub Actions | Gratis op publieke repo’s. Privérepo’s: 2.000 minuten en 500 MB artefacten per maand. | Actions-facturering |
| Cloudflare API | 1.200 verzoeken per vijf minuten per gebruiker. Eén Terraform-run doet er een handvol. | API-limieten |
Een blog dat een paar keer per week deployt blijft ver binnen al deze grenzen. De regel met 500 builds telt het eigen bouwsysteem van Cloudflare; de workflow hier uploadt kant-en-klare bestanden, en de documentatie zegt niet of dat meetelt. Bij een paar pushes per week maakt het geen verschil.
Samenvatting Link to heading
Eén repository bevat de site, de Terraform die de hosting beschrijft, en de workflow die beide uitrolt.
Cloudflare Pages serveert de bestanden, één CNAME wijst je domein ernaar, en R2 bewaart de Terraform-state met een lock.
Elke push toont een plan; een push naar main voert het uit en uploadt de site.
De opzet is een domein op Cloudflare, één bucket, twee tokens, vijf GitHub-secrets en twee aangepaste waarden. Daarna blijft het dashboard dicht. De rekening is het domein. De voorbeeldrepo legt elke stap vast als commit, en deze site en een paar andere draaien op dezelfde bestanden.
Hoe verder Link to heading
Preview-URL’s.
Geef --branch=${{ github.ref_name }} mee aan de uploadstap bij pushes naar een branch, en elke branch krijgt zijn eigen <branch>.<project>.pages.dev-adres. Documentatie direct upload.
Meer van de zone in Terraform.
Redirects, e-mailrouting en firewallregels zijn elk nog een resource-blok in dezelfde map. Terraform-handleiding van Cloudflare.
Eén ding kan een statische site niet zelf: een contactformulier. Het mijne draaide op een AWS Lambda en draait nu op een Cloudflare Worker. Beide zijn gratis bij dit volume, en één cloud is één login minder om in leven te houden. Die verhuizing krijgt een eigen stuk.