mykiwi/mykiwi.blogpublic Fork 0
e4c5d34
Commits
Clone
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add Cloudflare provisioning and deploy tooling

infra/ is OpenTofu (Terraform-compatible) config for the Cloudflare side
of the multihost setup: one Pages project, custom domain, and DNS
record per language, plus a www.mykiwi.blog -> mykiwi.blog redirect.
State is local and gitignored - fine for one person operating this.

flake.nix pins opentofu and wrangler the same way hugo/dart-sass
already are, in a separate `infra` devShell so writing a post doesn't
pull them in, and adds two flake apps: `infra` (forwards to tofu,
e.g. `nix run .#infra -- plan`) and `deploy` (builds the site and
pushes each language's output to its Cloudflare Pages project via
wrangler). The Makefile wraps both plus the everyday nix commands.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are read from .env
(gitignored) - .env.dist documents the shape without the secret.
Romain Gautier committed 2026-09-18T16:08:18+02:00 Browse files
e4c5d34 parent: f8c7bc5
added .env.dist +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+CLOUDFLARE_ACCOUNT_ID=d8149974c9ab26867fdc9cd579711d7d
2+CLOUDFLARE_API_TOKEN=
3+# https://dash.cloudflare.com/profile/api-tokens
new file mode 100644
@@ -0,0 +1,3 @@
1+CLOUDFLARE_ACCOUNT_ID=d8149974c9ab26867fdc9cd579711d7d
2+CLOUDFLARE_API_TOKEN=
3+# https://dash.cloudflare.com/profile/api-tokens
modified .gitignore +5 -3
@@ -1,5 +1,7 @@
1-result
2-public/
3-resources/
1+.env
42 .hugo_build.lock
3+
54 node_modules/
5+public/
6+resources/
7+result
@@ -1,5 +1,7 @@
1-result1+.env
2-public/
3-resources/
4 .hugo_build.lock2 .hugo_build.lock
3+
5 node_modules/4 node_modules/
5+public/
6+resources/
7+result
added Makefile +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+.DEFAULT_GOAL := help
2+
3+# CLOUDFLARE_API_TOKEN lives in .env (gitignored, copy from .env.dist) -
4+# load it into every recipe's environment, e.g. for infra-plan/infra-apply/deploy.
5+ifneq (,$(wildcard .env))
6+include .env
7+export
8+endif
9+
10+.PHONY: help dev build serve infra-plan infra-apply deploy clean
11+
12+help:
13+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
14+
15+dev: ## Drop into the Hugo dev shell (hugo, dart-sass)
16+ nix develop
17+
18+build: ## Build the site (all languages) into ./result
19+ nix build
20+
21+serve: ## Run the Hugo dev server with live reload
22+ nix develop --command hugo server
23+
24+infra-plan: ## Show what infra-apply would change on Cloudflare
25+ nix run .#infra -- plan
26+
27+infra-apply: ## Apply infra/ changes to Cloudflare
28+ nix run .#infra -- apply
29+
30+deploy: ## Build the site and push it to Cloudflare Pages (all languages)
31+ nix run .#deploy
32+
33+clean: ## Remove build outputs
34+ rm -rf result blog/public blog/resources
new file mode 100644
@@ -0,0 +1,34 @@
1+.DEFAULT_GOAL := help
2+
3+# CLOUDFLARE_API_TOKEN lives in .env (gitignored, copy from .env.dist) -
4+# load it into every recipe's environment, e.g. for infra-plan/infra-apply/deploy.
5+ifneq (,$(wildcard .env))
6+include .env
7+export
8+endif
9+
10+.PHONY: help dev build serve infra-plan infra-apply deploy clean
11+
12+help:
13+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
14+
15+dev: ## Drop into the Hugo dev shell (hugo, dart-sass)
16+ nix develop
17+
18+build: ## Build the site (all languages) into ./result
19+ nix build
20+
21+serve: ## Run the Hugo dev server with live reload
22+ nix develop --command hugo server
23+
24+infra-plan: ## Show what infra-apply would change on Cloudflare
25+ nix run .#infra -- plan
26+
27+infra-apply: ## Apply infra/ changes to Cloudflare
28+ nix run .#infra -- apply
29+
30+deploy: ## Build the site and push it to Cloudflare Pages (all languages)
31+ nix run .#deploy
32+
33+clean: ## Remove build outputs
34+ rm -rf result blog/public blog/resources
deleted blog/content/posts/hello-world.md +0 -9
deleted file mode 100644
@@ -1,9 +0,0 @@
1-+++
2-date = '2026-01-05T09:00:00+01:00'
3-draft = false
4-title = 'Hello World'
5-summary = 'A first post to kick things off.'
6-+++
7-
8-Welcome to my new blog, built with Hugo. This is a placeholder post used to
9-test the site layout and build pipeline.
deleted file mode 100644
@@ -1,9 +0,0 @@
1-+++
2-date = '2026-01-05T09:00:00+01:00'
3-draft = false
4-title = 'Hello World'
5-summary = 'A first post to kick things off.'
6-+++
7-
8-Welcome to my new blog, built with Hugo. This is a placeholder post used to
9-test the site layout and build pipeline.
deleted blog/content/posts/nix-flakes-for-reproducible-builds.md +0 -27
deleted file mode 100644
@@ -1,27 +0,0 @@
1-+++
2-date = '2026-02-14T10:30:00+01:00'
3-draft = false
4-title = 'Nix Flakes for Reproducible Builds'
5-summary = 'Notes on pinning tool versions with Nix flakes.'
6-+++
7-
8-Nix flakes make it easy to pin exact versions of dependencies, like the Hugo
9-binary used to build this blog, so every contributor gets identical builds
10-without relying on a system-wide package manager.
11-
12-## Pinning Hugo
13-
14-The `flake.nix` in this repo pins an exact Hugo release through
15-`nixpkgs-multiverse`:
16-
17-```nix
18-hugo = mv.version "hugo" "0.166.0";
19-```
20-
21-Running `nix develop` drops that version onto `PATH`, and `nix build` uses
22-the same pin to produce the site.
23-
24-{{% alert "tip" %}}
25-**Tip:** Pin the version once, in one file, and every machine that runs
26-`nix build` gets byte-identical output.
27-{{% /alert %}}
deleted file mode 100644
@@ -1,27 +0,0 @@
1-+++
2-date = '2026-02-14T10:30:00+01:00'
3-draft = false
4-title = 'Nix Flakes for Reproducible Builds'
5-summary = 'Notes on pinning tool versions with Nix flakes.'
6-+++
7-
8-Nix flakes make it easy to pin exact versions of dependencies, like the Hugo
9-binary used to build this blog, so every contributor gets identical builds
10-without relying on a system-wide package manager.
11-
12-## Pinning Hugo
13-
14-The `flake.nix` in this repo pins an exact Hugo release through
15-`nixpkgs-multiverse`:
16-
17-```nix
18-hugo = mv.version "hugo" "0.166.0";
19-```
20-
21-Running `nix develop` drops that version onto `PATH`, and `nix build` uses
22-the same pin to produce the site.
23-
24-{{% alert "tip" %}}
25-**Tip:** Pin the version once, in one file, and every machine that runs
26-`nix build` gets byte-identical output.
27-{{% /alert %}}
deleted blog/content/posts/static-sites-in-2026.md +0 -9
deleted file mode 100644
@@ -1,9 +0,0 @@
1-+++
2-date = '2026-03-02T08:15:00+01:00'
3-draft = false
4-title = 'Static Sites in 2026'
5-summary = 'Why static site generators are still a great choice.'
6-+++
7-
8-Static site generators like Hugo remain a fast, simple option for blogs:
9-no database, no runtime, just plain files served from anywhere.
deleted file mode 100644
@@ -1,9 +0,0 @@
1-+++
2-date = '2026-03-02T08:15:00+01:00'
3-draft = false
4-title = 'Static Sites in 2026'
5-summary = 'Why static site generators are still a great choice.'
6-+++
7-
8-Static site generators like Hugo remain a fast, simple option for blogs:
9-no database, no runtime, just plain files served from anywhere.
modified flake.nix +75 -13
@@ -10,10 +10,12 @@
1010 pkgs = pin.tip;
1111 hugo = pin.version "hugo" "0.166.0";
1212 dart-sass = pin.version "dart-sass" "1.104.0";
13+ opentofu = pin.version "opentofu" "1.12.6";
14+ wrangler = pin.version "wrangler" "4.129.0";
1315 in
1416 {
1517 packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {
16- name = "mykiwi-blog";
18+ name = "mykiwi.blog";
1719 src = ./blog;
1820 nativeBuildInputs = [ hugo dart-sass ];
1921 buildPhase = ''
@@ -22,20 +24,80 @@
2224 dontInstall = true;
2325 };
2426
25- devShells.${system}.default = pkgs.mkShell {
26- name = "mykiwi-blog";
27+ devShells.${system} = {
28+ default = pkgs.mkShell {
29+ name = "mykiwi-blog";
2730
28- packages = [
29- hugo
30- dart-sass
31- ];
31+ packages = [
32+ hugo
33+ dart-sass
34+ ];
3235
33- shellHook = ''
34- dim=$'\033[2m'
35- reset=$'\033[0m'
36- printf '%-11s%-9s%s%s%s\n' "Hugo" "$(hugo version | sed -n 's/.*v\([0-9.]*\)+.*/\1/p')" "$dim" "https://github.com/gohugoio/hugo/releases" "$reset"
37- printf '%-11s%-9s%s%s%s\n' "Dart Sass" "$(sass --version)" "$dim" "https://github.com/sass/dart-sass/releases" "$reset"
38- '';
36+ shellHook = ''
37+ dim=$'\033[2m'
38+ reset=$'\033[0m'
39+ printf '%-11s%-9s%s%s%s\n' "Hugo" "$(hugo version | sed -n 's/.*v\([0-9.]*\)+.*/\1/p')" "$dim" "https://github.com/gohugoio/hugo/releases" "$reset"
40+ printf '%-11s%-9s%s%s%s\n' "Dart Sass" "$(sass --version)" "$dim" "https://github.com/sass/dart-sass/releases" "$reset"
41+ '';
42+ };
43+
44+ # Cloudflare provisioning (infra/) - kept separate from the default
45+ # shell so writing a blog post doesn't require pulling in tofu/wrangler.
46+ infra = pkgs.mkShell {
47+ name = "mykiwi-blog-infra";
48+
49+ packages = [
50+ opentofu
51+ wrangler
52+ ];
53+
54+ shellHook = ''
55+ dim=$'\033[2m'
56+ reset=$'\033[0m'
57+ printf '%-11s%-9s%s%s%s\n' "OpenTofu" "$(tofu version | sed -n 's/OpenTofu v\([0-9.]*\)/\1/p')" "$dim" "https://github.com/opentofu/opentofu/releases" "$reset"
58+ printf '%-11s%-9s%s%s%s\n' "Wrangler" "$(wrangler --version)" "$dim" "https://github.com/cloudflare/workers-sdk/releases" "$reset"
59+ '';
60+ };
61+ };
62+
63+ apps.${system} = {
64+ # `nix run .#infra -- plan` / `nix run .#infra -- apply` (or any
65+ # other tofu subcommand) against infra/, from wherever in the repo
66+ # you happen to be.
67+ infra = {
68+ type = "app";
69+ program = "${pkgs.writeShellApplication {
70+ name = "mykiwi-blog-infra";
71+ runtimeInputs = [ opentofu pkgs.git ];
72+ text = ''
73+ root="$(git rev-parse --show-toplevel)"
74+ tofu -chdir="$root/infra" init
75+ tofu -chdir="$root/infra" "$@"
76+ '';
77+ }}/bin/mykiwi-blog-infra";
78+ };
79+
80+ # `nix run .#deploy` - builds the site (the same `packages.default`
81+ # derivation `nix build` uses) and pushes each language's output
82+ # folder to its matching Cloudflare Pages project. Needs
83+ # CLOUDFLARE_API_TOKEN, same as `infra`.
84+ deploy = {
85+ type = "app";
86+ program = "${pkgs.writeShellApplication {
87+ name = "mykiwi-blog-deploy";
88+ runtimeInputs = [ wrangler pkgs.git pkgs.nix ];
89+ text = ''
90+ root="$(git rev-parse --show-toplevel)"
91+ link="$(mktemp -d)/result"
92+ nix build "$root#default" --out-link "$link"
93+ for lang in en fr pt ja; do
94+ project="mykiwi-blog-$lang"
95+ echo "==> deploying $lang -> $project"
96+ wrangler pages deploy "$link/$lang" --project-name="$project" --branch=main
97+ done
98+ '';
99+ }}/bin/mykiwi-blog-deploy";
100+ };
39101 };
40102 };
41103 }
@@ -10,10 +10,12 @@
10 pkgs = pin.tip;10 pkgs = pin.tip;
11 hugo = pin.version "hugo" "0.166.0";11 hugo = pin.version "hugo" "0.166.0";
12 dart-sass = pin.version "dart-sass" "1.104.0";12 dart-sass = pin.version "dart-sass" "1.104.0";
13+ opentofu = pin.version "opentofu" "1.12.6";
14+ wrangler = pin.version "wrangler" "4.129.0";
13 in15 in
14 {16 {
15 packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {17 packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {
16- name = "mykiwi-blog";18+ name = "mykiwi.blog";
17 src = ./blog;19 src = ./blog;
18 nativeBuildInputs = [ hugo dart-sass ];20 nativeBuildInputs = [ hugo dart-sass ];
19 buildPhase = ''21 buildPhase = ''
@@ -22,20 +24,80 @@
22 dontInstall = true;24 dontInstall = true;
23 };25 };
24 26
25- devShells.${system}.default = pkgs.mkShell {27+ devShells.${system} = {
26- name = "mykiwi-blog";28+ default = pkgs.mkShell {
29+ name = "mykiwi-blog";
27 30
28- packages = [31+ packages = [
29- hugo32+ hugo
30- dart-sass33+ dart-sass
31- ];34+ ];
32 35
33- shellHook = ''36+ shellHook = ''
34- dim=$'\033[2m'37+ dim=$'\033[2m'
35- reset=$'\033[0m'38+ reset=$'\033[0m'
36- printf '%-11s%-9s%s%s%s\n' "Hugo" "$(hugo version | sed -n 's/.*v\([0-9.]*\)+.*/\1/p')" "$dim" "https://github.com/gohugoio/hugo/releases" "$reset"39+ printf '%-11s%-9s%s%s%s\n' "Hugo" "$(hugo version | sed -n 's/.*v\([0-9.]*\)+.*/\1/p')" "$dim" "https://github.com/gohugoio/hugo/releases" "$reset"
37- printf '%-11s%-9s%s%s%s\n' "Dart Sass" "$(sass --version)" "$dim" "https://github.com/sass/dart-sass/releases" "$reset"40+ printf '%-11s%-9s%s%s%s\n' "Dart Sass" "$(sass --version)" "$dim" "https://github.com/sass/dart-sass/releases" "$reset"
38- '';41+ '';
42+ };
43+
44+ # Cloudflare provisioning (infra/) - kept separate from the default
45+ # shell so writing a blog post doesn't require pulling in tofu/wrangler.
46+ infra = pkgs.mkShell {
47+ name = "mykiwi-blog-infra";
48+
49+ packages = [
50+ opentofu
51+ wrangler
52+ ];
53+
54+ shellHook = ''
55+ dim=$'\033[2m'
56+ reset=$'\033[0m'
57+ printf '%-11s%-9s%s%s%s\n' "OpenTofu" "$(tofu version | sed -n 's/OpenTofu v\([0-9.]*\)/\1/p')" "$dim" "https://github.com/opentofu/opentofu/releases" "$reset"
58+ printf '%-11s%-9s%s%s%s\n' "Wrangler" "$(wrangler --version)" "$dim" "https://github.com/cloudflare/workers-sdk/releases" "$reset"
59+ '';
60+ };
61+ };
62+
63+ apps.${system} = {
64+ # `nix run .#infra -- plan` / `nix run .#infra -- apply` (or any
65+ # other tofu subcommand) against infra/, from wherever in the repo
66+ # you happen to be.
67+ infra = {
68+ type = "app";
69+ program = "${pkgs.writeShellApplication {
70+ name = "mykiwi-blog-infra";
71+ runtimeInputs = [ opentofu pkgs.git ];
72+ text = ''
73+ root="$(git rev-parse --show-toplevel)"
74+ tofu -chdir="$root/infra" init
75+ tofu -chdir="$root/infra" "$@"
76+ '';
77+ }}/bin/mykiwi-blog-infra";
78+ };
79+
80+ # `nix run .#deploy` - builds the site (the same `packages.default`
81+ # derivation `nix build` uses) and pushes each language's output
82+ # folder to its matching Cloudflare Pages project. Needs
83+ # CLOUDFLARE_API_TOKEN, same as `infra`.
84+ deploy = {
85+ type = "app";
86+ program = "${pkgs.writeShellApplication {
87+ name = "mykiwi-blog-deploy";
88+ runtimeInputs = [ wrangler pkgs.git pkgs.nix ];
89+ text = ''
90+ root="$(git rev-parse --show-toplevel)"
91+ link="$(mktemp -d)/result"
92+ nix build "$root#default" --out-link "$link"
93+ for lang in en fr pt ja; do
94+ project="mykiwi-blog-$lang"
95+ echo "==> deploying $lang -> $project"
96+ wrangler pages deploy "$link/$lang" --project-name="$project" --branch=main
97+ done
98+ '';
99+ }}/bin/mykiwi-blog-deploy";
100+ };
39 };101 };
40 };102 };
41 }103 }
added infra/.gitignore +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+.terraform/
2+*.tfstate
3+*.tfstate.*
4+*.tfvars
5+crash.log
6+crash.*.log
7+override.tf
8+override.tf.json
9+*_override.tf
10+*_override.tf.json
new file mode 100644
@@ -0,0 +1,10 @@
1+.terraform/
2+*.tfstate
3+*.tfstate.*
4+*.tfvars
5+crash.log
6+crash.*.log
7+override.tf
8+override.tf.json
9+*_override.tf
10+*_override.tf.json
added infra/.terraform.lock.hcl +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+# This file is maintained automatically by "tofu init".
2+# Manual edits may be lost in future updates.
3+
4+provider "registry.opentofu.org/cloudflare/cloudflare" {
5+ version = "4.52.9"
6+ constraints = "~> 4.44"
7+ hashes = [
8+ "h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
9+ "h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
10+ "h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
11+ "h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
12+ "h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
13+ "h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
14+ "h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
15+ "h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
16+ "h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
17+ "h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
18+ "h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
19+ "h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
20+ "h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
21+ "zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
22+ "zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
23+ "zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
24+ "zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
25+ "zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
26+ "zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
27+ "zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
28+ "zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
29+ "zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
30+ "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
31+ "zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
32+ "zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
33+ "zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
34+ "zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
35+ ]
36+}
new file mode 100644
@@ -0,0 +1,36 @@
1+# This file is maintained automatically by "tofu init".
2+# Manual edits may be lost in future updates.
3+
4+provider "registry.opentofu.org/cloudflare/cloudflare" {
5+ version = "4.52.9"
6+ constraints = "~> 4.44"
7+ hashes = [
8+ "h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
9+ "h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
10+ "h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
11+ "h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
12+ "h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
13+ "h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
14+ "h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
15+ "h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
16+ "h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
17+ "h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
18+ "h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
19+ "h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
20+ "h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
21+ "zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
22+ "zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
23+ "zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
24+ "zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
25+ "zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
26+ "zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
27+ "zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
28+ "zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
29+ "zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
30+ "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
31+ "zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
32+ "zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
33+ "zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
34+ "zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
35+ ]
36+}
added infra/README.md +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+# infra
2+
3+OpenTofu config for the Cloudflare side of the multihost setup in
4+`blog/hugo.toml`: one Cloudflare Pages project, custom domain, and DNS
5+record per language (`mykiwi.blog`, `fr.mykiwi.blog`, `pt.mykiwi.blog`,
6+`ja.mykiwi.blog`). It assumes `mykiwi.blog` is already a zone in your
7+Cloudflare account - this only manages what's *inside* that zone.
8+
9+Deploys (`wrangler pages deploy public/<lang> --project-name=...`) are out
10+of scope here; this just provisions the projects and domains deploys land
11+on.
12+
13+## Setup
14+
15+Create a token at https://dash.cloudflare.com/profile/api-tokens (Custom
16+Token, scoped to the `mykiwi.blog` zone) with `Zone:DNS:Edit`,
17+`Zone:Zone:Read`, and `Account:Cloudflare Pages:Edit`, then:
18+
19+```sh
20+export CLOUDFLARE_API_TOKEN=...
21+```
22+
23+`cloudflare_account_id` is hardcoded in `variables.tf` (it's not a secret -
24+just an identifier) - override it with `-var` if you're ever applying
25+against a different account.
26+
27+## Usage
28+
29+From the repo root:
30+
31+```sh
32+make infra-plan
33+make infra-apply
34+```
35+
36+Or directly, from this directory (`nix develop .#infra` for `tofu`):
37+
38+```sh
39+tofu init
40+tofu plan
41+tofu apply
42+```
43+
44+State is local (`terraform.tfstate`, gitignored) - fine for a single person
45+operating this. If that ever changes, move the backend to something shared
46+(e.g. an R2 bucket) before a second person touches this.
new file mode 100644
@@ -0,0 +1,46 @@
1+# infra
2+
3+OpenTofu config for the Cloudflare side of the multihost setup in
4+`blog/hugo.toml`: one Cloudflare Pages project, custom domain, and DNS
5+record per language (`mykiwi.blog`, `fr.mykiwi.blog`, `pt.mykiwi.blog`,
6+`ja.mykiwi.blog`). It assumes `mykiwi.blog` is already a zone in your
7+Cloudflare account - this only manages what's *inside* that zone.
8+
9+Deploys (`wrangler pages deploy public/<lang> --project-name=...`) are out
10+of scope here; this just provisions the projects and domains deploys land
11+on.
12+
13+## Setup
14+
15+Create a token at https://dash.cloudflare.com/profile/api-tokens (Custom
16+Token, scoped to the `mykiwi.blog` zone) with `Zone:DNS:Edit`,
17+`Zone:Zone:Read`, and `Account:Cloudflare Pages:Edit`, then:
18+
19+```sh
20+export CLOUDFLARE_API_TOKEN=...
21+```
22+
23+`cloudflare_account_id` is hardcoded in `variables.tf` (it's not a secret -
24+just an identifier) - override it with `-var` if you're ever applying
25+against a different account.
26+
27+## Usage
28+
29+From the repo root:
30+
31+```sh
32+make infra-plan
33+make infra-apply
34+```
35+
36+Or directly, from this directory (`nix develop .#infra` for `tofu`):
37+
38+```sh
39+tofu init
40+tofu plan
41+tofu apply
42+```
43+
44+State is local (`terraform.tfstate`, gitignored) - fine for a single person
45+operating this. If that ever changes, move the backend to something shared
46+(e.g. an R2 bucket) before a second person touches this.
added infra/main.tf +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+# One Cloudflare Pages project + custom domain + DNS record per language,
2+# matching blog/hugo.toml's multihost [languages.*] baseURLs: each language
3+# is a fully separate site (public/<lang>/ from the Hugo build) deployed to
4+# its own subdomain, with English at the zone apex.
5+locals {
6+ languages = {
7+ en = { hostname = var.zone_name, project_name = "mykiwi-blog-en" }
8+ fr = { hostname = "fr.${var.zone_name}", project_name = "mykiwi-blog-fr" }
9+ pt = { hostname = "pt.${var.zone_name}", project_name = "mykiwi-blog-pt" }
10+ ja = { hostname = "ja.${var.zone_name}", project_name = "mykiwi-blog-ja" }
11+ }
12+}
13+
14+data "cloudflare_zone" "this" {
15+ name = var.zone_name
16+}
17+
18+# Direct-upload projects (no `source` block, i.e. no git integration) -
19+# deploys happen out-of-band, e.g. `wrangler pages deploy public/en
20+# --project-name=mykiwi-blog-en` from CI, since this repo lives on
21+# tangled.sh rather than GitHub.
22+resource "cloudflare_pages_project" "site" {
23+ for_each = local.languages
24+
25+ account_id = var.cloudflare_account_id
26+ name = each.value.project_name
27+ production_branch = "main"
28+}
29+
30+resource "cloudflare_pages_domain" "site" {
31+ for_each = local.languages
32+
33+ account_id = var.cloudflare_account_id
34+ project_name = cloudflare_pages_project.site[each.key].name
35+ domain = each.value.hostname
36+}
37+
38+resource "cloudflare_record" "site" {
39+ for_each = local.languages
40+
41+ zone_id = data.cloudflare_zone.this.id
42+ name = each.key == "en" ? "@" : each.key
43+ type = "CNAME"
44+ content = cloudflare_pages_project.site[each.key].subdomain
45+ proxied = true
46+}
47+
48+# www.mykiwi.blog -> mykiwi.blog (English apex), not its own language - just
49+# the one common typo/habit redirect, not a 5th site.
50+resource "cloudflare_record" "www" {
51+ zone_id = data.cloudflare_zone.this.id
52+ name = "www"
53+ type = "CNAME"
54+ content = var.zone_name
55+ proxied = true
56+}
57+
58+resource "cloudflare_page_rule" "www_redirect" {
59+ zone_id = data.cloudflare_zone.this.id
60+ target = "www.${var.zone_name}/*"
61+ priority = 1
62+
63+ actions {
64+ forwarding_url {
65+ url = "https://${var.zone_name}/$1"
66+ status_code = 301
67+ }
68+ }
69+}
new file mode 100644
@@ -0,0 +1,69 @@
1+# One Cloudflare Pages project + custom domain + DNS record per language,
2+# matching blog/hugo.toml's multihost [languages.*] baseURLs: each language
3+# is a fully separate site (public/<lang>/ from the Hugo build) deployed to
4+# its own subdomain, with English at the zone apex.
5+locals {
6+ languages = {
7+ en = { hostname = var.zone_name, project_name = "mykiwi-blog-en" }
8+ fr = { hostname = "fr.${var.zone_name}", project_name = "mykiwi-blog-fr" }
9+ pt = { hostname = "pt.${var.zone_name}", project_name = "mykiwi-blog-pt" }
10+ ja = { hostname = "ja.${var.zone_name}", project_name = "mykiwi-blog-ja" }
11+ }
12+}
13+
14+data "cloudflare_zone" "this" {
15+ name = var.zone_name
16+}
17+
18+# Direct-upload projects (no `source` block, i.e. no git integration) -
19+# deploys happen out-of-band, e.g. `wrangler pages deploy public/en
20+# --project-name=mykiwi-blog-en` from CI, since this repo lives on
21+# tangled.sh rather than GitHub.
22+resource "cloudflare_pages_project" "site" {
23+ for_each = local.languages
24+
25+ account_id = var.cloudflare_account_id
26+ name = each.value.project_name
27+ production_branch = "main"
28+}
29+
30+resource "cloudflare_pages_domain" "site" {
31+ for_each = local.languages
32+
33+ account_id = var.cloudflare_account_id
34+ project_name = cloudflare_pages_project.site[each.key].name
35+ domain = each.value.hostname
36+}
37+
38+resource "cloudflare_record" "site" {
39+ for_each = local.languages
40+
41+ zone_id = data.cloudflare_zone.this.id
42+ name = each.key == "en" ? "@" : each.key
43+ type = "CNAME"
44+ content = cloudflare_pages_project.site[each.key].subdomain
45+ proxied = true
46+}
47+
48+# www.mykiwi.blog -> mykiwi.blog (English apex), not its own language - just
49+# the one common typo/habit redirect, not a 5th site.
50+resource "cloudflare_record" "www" {
51+ zone_id = data.cloudflare_zone.this.id
52+ name = "www"
53+ type = "CNAME"
54+ content = var.zone_name
55+ proxied = true
56+}
57+
58+resource "cloudflare_page_rule" "www_redirect" {
59+ zone_id = data.cloudflare_zone.this.id
60+ target = "www.${var.zone_name}/*"
61+ priority = 1
62+
63+ actions {
64+ forwarding_url {
65+ url = "https://${var.zone_name}/$1"
66+ status_code = 301
67+ }
68+ }
69+}
added infra/outputs.tf +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+output "pages_subdomains" {
2+ description = "The <project>.pages.dev address behind each custom domain - useful for deploying, e.g. `wrangler pages deploy public/en --project-name=mykiwi-blog-en`."
3+ value = { for k, v in cloudflare_pages_project.site : k => v.subdomain }
4+}
5+
6+output "hostnames" {
7+ description = "The final public hostname for each language."
8+ value = { for k, v in local.languages : k => v.hostname }
9+}
new file mode 100644
@@ -0,0 +1,9 @@
1+output "pages_subdomains" {
2+ description = "The <project>.pages.dev address behind each custom domain - useful for deploying, e.g. `wrangler pages deploy public/en --project-name=mykiwi-blog-en`."
3+ value = { for k, v in cloudflare_pages_project.site : k => v.subdomain }
4+}
5+
6+output "hostnames" {
7+ description = "The final public hostname for each language."
8+ value = { for k, v in local.languages : k => v.hostname }
9+}
added infra/variables.tf +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+variable "cloudflare_account_id" {
2+ # Not a secret - it's just an identifier (visible in any Cloudflare
3+ # dashboard URL for this account), meaningless without the API token.
4+ description = "Cloudflare account ID that owns the zone and will own the Pages projects."
5+ type = string
6+ default = "d8149974c9ab26867fdc9cd579711d7d"
7+}
8+
9+variable "zone_name" {
10+ description = "Root domain already managed in Cloudflare (must exist as a zone before applying)."
11+ type = string
12+ default = "mykiwi.blog"
13+}
new file mode 100644
@@ -0,0 +1,13 @@
1+variable "cloudflare_account_id" {
2+ # Not a secret - it's just an identifier (visible in any Cloudflare
3+ # dashboard URL for this account), meaningless without the API token.
4+ description = "Cloudflare account ID that owns the zone and will own the Pages projects."
5+ type = string
6+ default = "d8149974c9ab26867fdc9cd579711d7d"
7+}
8+
9+variable "zone_name" {
10+ description = "Root domain already managed in Cloudflare (must exist as a zone before applying)."
11+ type = string
12+ default = "mykiwi.blog"
13+}
added infra/versions.tf +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+terraform {
2+ required_version = ">= 1.6.0"
3+
4+ required_providers {
5+ cloudflare = {
6+ source = "cloudflare/cloudflare"
7+ version = "~> 4.44"
8+ }
9+ }
10+}
11+
12+# CLOUDFLARE_API_TOKEN is read from the environment automatically by the
13+# provider - never pass the token in as a variable or tfvars value, since
14+# that would land it in the (unencrypted, locally-stored) state file.
15+provider "cloudflare" {}
new file mode 100644
@@ -0,0 +1,15 @@
1+terraform {
2+ required_version = ">= 1.6.0"
3+
4+ required_providers {
5+ cloudflare = {
6+ source = "cloudflare/cloudflare"
7+ version = "~> 4.44"
8+ }
9+ }
10+}
11+
12+# CLOUDFLARE_API_TOKEN is read from the environment automatically by the
13+# provider - never pass the token in as a variable or tfvars value, since
14+# that would land it in the (unencrypted, locally-stored) state file.
15+provider "cloudflare" {}