Skip to content
forkcloserPublic

About

forkcloser's web infrastructure as code: Cloudflare Pages through OpenTofu, DNS and registrar through dnsctl, one zone file

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

infra

The infrastructure behind forkcloser's websites, as code. One zone per domain the organisation owns, one block per site; a pull request adds a site, and the apply on main creates it. Nothing is configured by hand except what no API in this repository's reach offers: the Cloudflare Pages GitHub App installed on the organisation, once, the two environments on this repository, and the accounts and tokens themselves.

Two tools do the work, and both are run by the same recipes. OpenTofu holds what lives at Cloudflare: the Pages project pulling a site's repository, and its custom domain. dnsctl, a Go program in this repository, holds DNS and the registrar: it makes deSEC and Gandi match zones.yaml, the zone, its record sets, the delegation, and the DNSSEC key published at the registrar. No third-party provider stands between us and either API; the Cloudflare provider is Cloudflare's own.

This is the pilot. The static-site module lives here as a local directory while godolint goes through it; once it stops moving it is extracted to a module repository referenced by tag, and the recipes below to limen, leaving this repository its declarations.

Layout

Path What
zones.yaml Every domain and every record set. The file is the whole zone: a record set in DNS and not here is deleted on apply.
cmd/dnsctl, internal/ The DNS and registrar tool: desec and gandi are the two API clients, zones reads and validates the file, reconcile computes and applies the diff.
versions.tf The OpenTofu and provider pins, the S3 backend on R2, and state encryption.
backend.hcl The backend's bucket, key and R2 flags. The endpoint, which carries the account id, comes from the environment at just init.
sites.tf One static-site module block per site.
modules/static-site A Cloudflare Pages project pulling a GitHub repository on push, and its custom domain.
.github/workflows/infra.yaml On push to main: plan in the plan environment, then apply in production after the owner's approval. A pull request runs no plan.

Working on it

just lint      # the shared limen baseline and its Go lanes, plus tofu fmt and validate
just test      # the Go tests, validate with the provider and without the backend, and check zones.yaml
just plan      # the Cloudflare plan to build/tofu.plan, then the DNS and registrar diff
just apply     # apply build/tofu.plan, and only that; then DNS and the registrar
just lock      # refresh provider hashes for every platform in the lock file

Plan and apply read every credential from the environment and nothing from the tree:

Variable What
CLOUDFLARE_ACCOUNT_ID, TF_VAR_cloudflare_account_id The account that owns the Pages projects and the state bucket.
CLOUDFLARE_API_TOKEN Pages on that account, nothing else: Cloudflare Pages Read for a plan, Cloudflare Pages Edit for an apply.
DESEC_API_TOKEN A deSEC token on this organisation's zones that may not create any. A plan's has perm_write false on its default policy.
DESEC_CREATE_TOKEN A deSEC token whose only right is to create domains. Apply reads it when a zone in the file does not exist yet and refuses up front, before writing anything, when it is needed and absent; plan reports the creation without it.
GANDI_PERSONAL_ACCESS_TOKEN A Gandi personal access token on this organisation's domains, nothing else: one that sees them for a plan, one that manages them for an apply.
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY An R2 token scoped to the state bucket: Object Read only for a plan, which then runs with -lock=false since the lock is a write; Object Read & Write for an apply.
TF_VAR_state_passphrase The passphrase the state encryption key derives from. A plan needs it to read the state.

In the workflow every secret is an environment's, and the repository level holds none. The plan environment holds the read-only tokens, the account id and the passphrase; it admits main alone and has no reviewer, so the plan is printed as soon as main moves. The production environment holds the write tokens, the creation token, the account id and the passphrase; it admits main alone and waits for the owner's approval, given with the plan job's output in view. Both jobs name the same secrets; the environment decides which value a job gets. A pull request runs no plan: GitHub hands a secret a job names to any run in the repository's own context, a pull request with a modified workflow included, so a token every pull request reads is one any write account prints, whatever its scope. Pull requests verify the tree: just lint and just test, which validate the OpenTofu configuration without the backend and load and validate zones.yaml. What a merge changes at the providers is shown by the plan on main, before the apply waits for approval.

The state is ciphertext in the bucket and the plan file is ciphertext on disk; OpenTofu is set to refuse either unencrypted. Locking is the bucket's own conditional write.

dnsctl keeps no state: the file and the two services are compared on every run. Two deSEC tokens keep creation apart from routine writes: the creation token is used for one call, creating a zone, and nothing else ever carries that right. dnsctl plan exits 2 when something would change, 0 when nothing would; the plan recipe accepts both, since a plan with changes in it is the point of running one before an apply. Apply runs in the order the DNSSEC chain needs: the zone, its record sets, the delegation, and only then the key at the registrar, so a resolver never sees a signed delegation to a zone still served unsigned. A zone created in a run has its key published in the same run; deSEC returns the keys on creation.

Adding a site

One block in sites.tf:

module "example" {
  source = "./modules/static-site"

  account_id = var.cloudflare_account_id
  name       = "website-example"   # the Pages project, and its pages.dev hostname
  repository = "website-example"   # pulled from github.com/forkcloser on push
  zone       = "forkcloser.org"
  subname    = "example"           # example.forkcloser.org
}

And one record set in zones.yaml, the CNAME to the project's pages.dev name, absolute:

      - subname: example
        type: CNAME
        ttl: 3600
        records: ["website-example.pages.dev."]

The content repository needs nothing from this one: Cloudflare pulls it on every push, production from main, a preview per branch, serving its site/ directory as committed. One thing comes first: the Cloudflare Pages App on the organisation must have access to the repository before the apply, because Cloudflare binds the repository to the project at creation and never again; a project created before the access existed builds nothing, on any push, and its hostname answers 522. The way back is to replace the project: bump the block's connection (1 by default) and the apply destroys the project and its domain and creates them again, with the connection made under the access that exists now.

Adding a domain

One zone in zones.yaml, registrar gandi. The first apply creates the zone at deSEC, writes its record sets, points the delegation at deSEC's nameservers, and publishes the zone's signing key at Gandi.

About

forkcloser's web infrastructure as code: Cloudflare Pages through OpenTofu, DNS and registrar through dnsctl, one zone file

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages