website
git clone https://git.pyrossh.dev/website
木 Personal website of pyrossh. Built with astrojs, shiki, vite.
infra/main.tf
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
}
provider "cloudflare" {}
resource "cloudflare_r2_bucket" "website" {
account_id = var.cloudflare_account_id
name = "website"
}
resource "cloudflare_r2_custom_domain" "website" {
account_id = var.cloudflare_account_id
bucket_name = cloudflare_r2_bucket.website.name
domain = "pyrossh.dev"
zone_id = var.cloudflare_zone_id
enabled = true
}
resource "cloudflare_r2_bucket" "git" {
account_id = var.cloudflare_account_id
name = "git"
}
resource "cloudflare_r2_custom_domain" "git" {
account_id = var.cloudflare_account_id
bucket_name = cloudflare_r2_bucket.git.name
domain = "git.pyrossh.dev"
zone_id = var.cloudflare_zone_id
enabled = true
}
resource "cloudflare_r2_bucket" "apps" {
account_id = var.cloudflare_account_id
name = "apps"
}
# Runs the Eleventy build on every `terraform apply`, before the sync below,
# so `terraform apply` alone is enough — no separate `npm run build` step
# needed first. Same "always re-run" trigger pattern as sync_website.
resource "terraform_data" "build_website" {
triggers_replace = [timestamp()]
provisioner "local-exec" {
working_dir = "${path.module}/.."
command = "npm run build"
}
}
# Syncs ../dist into the bucket on every `terraform apply` — `terraform_data`
# has no real infra state of its own, so `triggers_replace` on a timestamp is
# the standard way to force its provisioner to re-run every time rather than
# only on the first apply. `rclone sync` (not `copy`) both uploads changed
# files and deletes remote objects no longer present locally, so the bucket
# always matches the latest build exactly.
#
# Requires `rclone` on the machine running `terraform apply`, with a remote
# named "website" configured for R2 (`rclone config`) — an S3-compatible
# access key/secret scoped to this bucket, not the Cloudflare API token used
# above.
#
resource "terraform_data" "sync_website" {
triggers_replace = [timestamp()]
depends_on = [cloudflare_r2_bucket.website, terraform_data.build_website]
provisioner "local-exec" {
command = "rclone sync ${path.module}/../dist website:${cloudflare_r2_bucket.website.name} --checksum --fast-list --multi-thread-streams=32 -P "
}
}
# The custom domain sits behind Cloudflare's edge cache, and by default only
# static-asset extensions (css/js/images/fonts) are cached there — not HTML.
# Since the sync reuses the same URLs on every deploy (no content-hashed
# filenames), an updated asset at an unchanged path would keep serving the
# stale cached copy at the edge until it expires on its own. Purging right
# after the sync avoids that gap. Requires the CLOUDFLARE_API_TOKEN already
# used above to also carry the "Zone > Cache Purge > Purge" permission for
# this zone — it isn't included in the R2-only scope from the setup steps
# earlier, so add it to the token if the purge call starts failing with 403.
#
# Kept as its own resource (not tacked onto sync_website's command) so a
# flaky purge call fails independently of the sync — you can tell from the
# apply output which of the two actually broke, rather than one opaque
# multi-line script's exit code.
resource "terraform_data" "purge_cache" {
triggers_replace = [timestamp()]
depends_on = [terraform_data.sync_website]
provisioner "local-exec" {
command = <<-EOT
curl -sf --retry 3 -X POST "https://api.cloudflare.com/client/v4/zones/${var.cloudflare_zone_id}/purge_cache" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'
EOT
}
}