What this PR is

The third round of the zo-k8s slim-down (#22, #24). It removes two pieces of machinery that had no live footprint: echo-demo.yaml, the spin-up-and-delete cluster smoke test, and infra/k8s/everwhen-preview/, the per-PR ephemeral deploy tooling for everwhen. A pre-teardown check confirmed nothing was running — no echo Deployment or Service, no everwhen-pr-* namespaces, and no LoadBalancer beyond the permanent ingress one.

The only live resource was DNS: the *.everwhen.mseeks.me wildcard existed solely so pr-<N>.everwhen.mseeks.me hosts would resolve without per-PR DNS changes. It was destroyed with a targeted apply, and both Terraform state and the live zone verified clean. The rest of the PR is deleting the code and the instructions that pointed at it.

dns.tf — the wildcard goes

The mseeks_me_everwhen_wildcard A record and its long rationale comment are deleted. Everwhen production is unaffected: it lives on playeverwhen.com (whose apex/www records stay), and the wildcard never matched a bare apex anyway — it only covered the preview hosts underneath it.

The static record now runs straight into the msull92.com zone where the wildcard used to sit.

infra/dns.tf · 359 lines
infra/dns.tf359 lines · Terraform
⋯ 113 lines hidden (lines 1–113)
1# DNS — reverse-engineered from the live DigitalOcean zones and imported into
2# state (target: `terraform plan` reports no changes).
3#
4# SOA and the default DigitalOcean NS records (ns1/2/3.digitalocean.com) are
5# managed automatically by DO and are intentionally NOT declared here.
6#
7# Every zone here is an iCloud Custom Email Domain (Apple) setup: MX -> icloud
8# mail, an SPF TXT, an apple-domain verification TXT, and a DKIM CNAME.
9# (mseeks.me additionally carries the zo-k8s load-balancer A records below.)
11# SPF quoting gotcha: write SPF TXT values WITHOUT surrounding quotes (e.g.
12# `v=spf1 include:icloud.com ~all`). DigitalOcean adds the DNS wire quotes on
13# its own, so escaping quotes into the value makes `terraform apply` CREATE a
14# doubly-quoted record (`""v=spf1 ...""`) that SPF verifiers reject. `plan`
15# won't warn you: the provider compares TXT values quote-insensitively, so even
16# a broken live record shows no drift. The mseeks.me / msull92.com SPF entries
17# still carry escaped quotes but resolve clean (created pre-terraform); left
18# as-is because recreating them from the quoted value would re-break them.
19# playeverwhen.com and 2ls.tech below use the correct unquoted form.
20 
21# ============================== mseeks.me ==============================
22 
23resource "digitalocean_domain" "mseeks_me" {
24 name = "mseeks.me"
26 
27resource "digitalocean_record" "mseeks_me_mx_01" {
28 domain = digitalocean_domain.mseeks_me.name
29 type = "MX"
30 name = "@"
31 value = "mx01.mail.icloud.com."
32 priority = 10
33 ttl = 14400
35 
36resource "digitalocean_record" "mseeks_me_mx_02" {
37 domain = digitalocean_domain.mseeks_me.name
38 type = "MX"
39 name = "@"
40 value = "mx02.mail.icloud.com."
41 priority = 10
42 ttl = 14400
44 
45resource "digitalocean_record" "mseeks_me_apple_domain" {
46 domain = digitalocean_domain.mseeks_me.name
47 type = "TXT"
48 name = "@"
49 value = "apple-domain=Y94Tzo0dJVhRhQxf"
50 ttl = 3600
52 
53resource "digitalocean_record" "mseeks_me_spf" {
54 domain = digitalocean_domain.mseeks_me.name
55 type = "TXT"
56 name = "@"
57 value = "\"v=spf1 include:icloud.com ~all\""
58 ttl = 3600
60 
61resource "digitalocean_record" "mseeks_me_dkim" {
62 domain = digitalocean_domain.mseeks_me.name
63 type = "CNAME"
64 name = "sig1._domainkey"
65 value = "sig1.dkim.mseeks.me.at.icloudmailadmin.com."
66 ttl = 43200
68 
69# ynab.mseeks.me -> the zo-k8s ingress Load Balancer. The LB itself is owned by
70# the DigitalOcean CCM (via the ingress-nginx Service, see infra/k8s/ingress-nginx);
71# Terraform only READS it through data.digitalocean_loadbalancer.ingress
72# (loadbalancer.tf) to keep this record's IP in sync automatically.
74# LOAD-BEARING — keep it. This is the LB's advertised hostname
75# (do-loadbalancer-hostname in infra/k8s/ingress-nginx/values.yaml). DO LBs don't
76# hairpin, so cert-manager's HTTP-01 self-check for EVERY host on the LB
77# (playeverwhen.com, static) resolves this name out via DNS
78# and back in. The ynab-agent app it was originally named for has been retired,
79# but the record stays as pure LB infra — deleting it breaks TLS renewal
80# cluster-wide.
81resource "digitalocean_record" "mseeks_me_ynab" {
82 domain = digitalocean_domain.mseeks_me.name
83 type = "A"
84 name = "ynab"
85 # Sourced from the live LB; if the LB is recreated (new IP), a plain
86 # `terraform apply` re-points this record — no hardcoded address to chase.
87 value = data.digitalocean_loadbalancer.ingress.ip
88 # Short TTL: this points at a DO LB whose IP can change if the LB is recreated,
89 # so keep resolver caches from pinning a stale address for long.
90 ttl = 300
92 
93# glassbox.mseeks.me -> Vercel. Glassbox is hosted on Vercel, not the cluster:
94# the project + custom domain are managed in the Vercel dashboard and deploys
95# ride its GitHub integration (push to main in mseeks/glassbox), so the only
96# infra Terraform owns is this record. Vercel terminates TLS at its edge — no
97# cert-manager involvement. The value is this project's own CNAME target, the
98# one the dashboard issued when glassbox.mseeks.me was added to the project
99# (Vercel mints per-project targets; the old universal cname.vercel-dns.com is
100# legacy). If the Vercel project is ever deleted and recreated, it gets a new
101# target — read the fresh value off the project's Domains tab, and only
102# re-point this record once the domain is attached there (flipped early, the
103# host serves Vercel's DEPLOYMENT_NOT_FOUND page).
104resource "digitalocean_record" "mseeks_me_glassbox" {
105 domain = digitalocean_domain.mseeks_me.name
106 type = "CNAME"
107 name = "glassbox"
108 value = "e24be5a7fd9d016f.vercel-dns-017.com."
109 # Unlike the LB A records above, the target here is stable for the life of
110 # the Vercel project, so no short-TTL churn guard is needed.
111 ttl = 3600
113 
114# static.mseeks.me -> the same zo-k8s ingress Load Balancer. Public entrypoint
115# for the `static` drop-and-share site (https://static.mseeks.me; see
116# infra/k8s/static) and the host cert-manager gets a Let's Encrypt cert for.
117# Same read-only LB-IP sourcing as ynab above — no hardcoded address to
118# chase.
119resource "digitalocean_record" "mseeks_me_static" {
120 domain = digitalocean_domain.mseeks_me.name
121 type = "A"
122 name = "static"
123 value = data.digitalocean_loadbalancer.ingress.ip
124 # Short TTL for the same reason as ynab: the target is a DO LB whose
125 # IP can change if the LB is recreated.
126 ttl = 300
128 
129# ============================== msull92.com ==============================
⋯ 230 lines hidden (lines 130–359)
130 
131resource "digitalocean_domain" "msull92_com" {
132 name = "msull92.com"
134 
135resource "digitalocean_record" "msull92_com_mx_01" {
136 domain = digitalocean_domain.msull92_com.name
137 type = "MX"
138 name = "@"
139 value = "mx01.mail.icloud.com."
140 priority = 10
141 ttl = 14400
143 
144resource "digitalocean_record" "msull92_com_mx_02" {
145 domain = digitalocean_domain.msull92_com.name
146 type = "MX"
147 name = "@"
148 value = "mx02.mail.icloud.com."
149 priority = 10
150 ttl = 14400
152 
153resource "digitalocean_record" "msull92_com_apple_domain" {
154 domain = digitalocean_domain.msull92_com.name
155 type = "TXT"
156 name = "@"
157 value = "apple-domain=SLKQpEYsouHgb3mI"
158 ttl = 3600
160 
161resource "digitalocean_record" "msull92_com_spf" {
162 domain = digitalocean_domain.msull92_com.name
163 type = "TXT"
164 name = "@"
165 value = "\"v=spf1 include:icloud.com ~all\""
166 ttl = 3600
168 
169resource "digitalocean_record" "msull92_com_dkim" {
170 domain = digitalocean_domain.msull92_com.name
171 type = "CNAME"
172 name = "sig1._domainkey"
173 value = "sig1.dkim.msull92.com.at.icloudmailadmin.com."
174 ttl = 43200
176 
177# ============================== 2ls.tech ==============================
178# HCL resource names can't start with a digit, so these use a `_2ls_tech`
179# prefix (a leading underscore is valid) rather than the plain `2ls_tech`
180# the mseeks_me/msull92_com convention would suggest.
181 
182resource "digitalocean_domain" "_2ls_tech" {
183 name = "2ls.tech"
185 
186resource "digitalocean_record" "_2ls_tech_mx_01" {
187 domain = digitalocean_domain._2ls_tech.name
188 type = "MX"
189 name = "@"
190 value = "mx01.mail.icloud.com."
191 priority = 10
192 ttl = 14400
194 
195resource "digitalocean_record" "_2ls_tech_mx_02" {
196 domain = digitalocean_domain._2ls_tech.name
197 type = "MX"
198 name = "@"
199 value = "mx02.mail.icloud.com."
200 priority = 10
201 ttl = 14400
203 
204resource "digitalocean_record" "_2ls_tech_apple_domain" {
205 domain = digitalocean_domain._2ls_tech.name
206 type = "TXT"
207 name = "@"
208 value = "apple-domain=KAYDndJUw5TrEwCe"
209 ttl = 3600
211 
212# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
213# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
214# itself (see the SPF quoting gotcha at the top of this file).
215resource "digitalocean_record" "_2ls_tech_google_site_verification" {
216 domain = digitalocean_domain._2ls_tech.name
217 type = "TXT"
218 name = "@"
219 value = "google-site-verification=9e1Dm56-fdkP2jTv1pGNI5II2ol0pR6Xtw8RtS1gXwM"
220 ttl = 3600
222 
223# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
224# file. Recreated once (terraform apply -replace) to strip the doubly-quoted
225# `""v=spf1 ...""` the original escaped-quote value produced on create.
226resource "digitalocean_record" "_2ls_tech_spf" {
227 domain = digitalocean_domain._2ls_tech.name
228 type = "TXT"
229 name = "@"
230 value = "v=spf1 include:icloud.com ~all"
231 ttl = 3600
233 
234resource "digitalocean_record" "_2ls_tech_dkim" {
235 domain = digitalocean_domain._2ls_tech.name
236 type = "CNAME"
237 name = "sig1._domainkey"
238 value = "sig1.dkim.2ls.tech.at.icloudmailadmin.com."
239 ttl = 43200
241 
242# 2ls.tech (+ www) -> Vercel. The Two Ls company site is hosted on Vercel, not
243# the cluster: the project + both domains are managed in the Vercel dashboard
244# and deploys ride its GitHub integration (push to main in mseeks/2ls.tech).
245# Vercel terminates TLS at its edge (no cert-manager), serves the apex, and
246# redirects www to it. The mail/verification records above are unaffected.
248# Values: both are this project's own targets, issued by the dashboard when
249# the domains were added (Vercel mints per-project values now; the universal
250# 76.76.21.21 / cname.vercel-dns.com are legacy). If the Vercel project is
251# ever deleted and recreated it gets new targets — read them off the
252# project's Domains tab, and only re-point these records once the domains
253# are attached there.
254resource "digitalocean_record" "_2ls_tech_apex" {
255 domain = digitalocean_domain._2ls_tech.name
256 type = "A"
257 name = "@"
258 value = "216.198.79.1"
259 # Stable Vercel target (unlike the LB IP this used to track), so no
260 # short-TTL churn guard is needed.
261 ttl = 3600
263 
264resource "digitalocean_record" "_2ls_tech_www" {
265 domain = digitalocean_domain._2ls_tech.name
266 type = "CNAME"
267 name = "www"
268 value = "bae588b1e31b34e9.vercel-dns-017.com."
269 ttl = 3600
271 
272# ============================== playeverwhen.com ==============================
273 
274resource "digitalocean_domain" "playeverwhen_com" {
275 name = "playeverwhen.com"
277 
278resource "digitalocean_record" "playeverwhen_com_mx_01" {
279 domain = digitalocean_domain.playeverwhen_com.name
280 type = "MX"
281 name = "@"
282 value = "mx01.mail.icloud.com."
283 priority = 10
284 ttl = 14400
286 
287resource "digitalocean_record" "playeverwhen_com_mx_02" {
288 domain = digitalocean_domain.playeverwhen_com.name
289 type = "MX"
290 name = "@"
291 value = "mx02.mail.icloud.com."
292 priority = 10
293 ttl = 14400
295 
296resource "digitalocean_record" "playeverwhen_com_apple_domain" {
297 domain = digitalocean_domain.playeverwhen_com.name
298 type = "TXT"
299 name = "@"
300 value = "apple-domain=DEESFXkRhJ4wE6Lm"
301 ttl = 3600
303 
304# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
305# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
306# itself (see the SPF quoting gotcha at the top of this file).
307resource "digitalocean_record" "playeverwhen_com_google_site_verification" {
308 domain = digitalocean_domain.playeverwhen_com.name
309 type = "TXT"
310 name = "@"
311 value = "google-site-verification=1FSmsnv2MPG_mKoCrdlSABXfyXFTaeVBETNRmLNi85o"
312 ttl = 3600
314 
315# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
316# file. DigitalOcean adds the DNS wire quotes itself.
317resource "digitalocean_record" "playeverwhen_com_spf" {
318 domain = digitalocean_domain.playeverwhen_com.name
319 type = "TXT"
320 name = "@"
321 value = "v=spf1 include:icloud.com ~all"
322 ttl = 3600
324 
325resource "digitalocean_record" "playeverwhen_com_dkim" {
326 domain = digitalocean_domain.playeverwhen_com.name
327 type = "CNAME"
328 name = "sig1._domainkey"
329 value = "sig1.dkim.playeverwhen.com.at.icloudmailadmin.com."
330 ttl = 43200
332 
333# playeverwhen.com (apex) -> the zo-k8s ingress Load Balancer. Public entrypoint
334# for the Everwhen SSR app (https://playeverwhen.com; see infra/k8s/everwhen),
335# migrated here from everwhen.mseeks.me. cert-manager gets a Let's Encrypt cert
336# for it (HTTP-01, so the apex must resolve to the LB). An apex A record (a CNAME
337# is illegal at a zone apex) works because the value is the LB IP, sourced
338# read-only from data.digitalocean_loadbalancer.ingress — no hardcoded address to
339# chase. Coexists fine with the iCloud MX/TXT records above.
340resource "digitalocean_record" "playeverwhen_com_apex" {
341 domain = digitalocean_domain.playeverwhen_com.name
342 type = "A"
343 name = "@"
344 value = data.digitalocean_loadbalancer.ingress.ip
345 # Short TTL: the target is a DO LB whose IP can change if the LB is recreated.
346 ttl = 300
348 
349# www.playeverwhen.com -> the same LB. Serves only a 301 redirect to the apex
350# (see the everwhen-www-redirect Ingress in infra/k8s/everwhen); cert-manager
351# issues its own cert so the redirect is reachable over HTTPS. Same read-only
352# LB-IP sourcing as the apex.
353resource "digitalocean_record" "playeverwhen_com_www" {
354 domain = digitalocean_domain.playeverwhen_com.name
355 type = "A"
356 name = "www"
357 value = data.digitalocean_loadbalancer.ingress.ip
358 ttl = 300

The LB cost note gets more honest

Both kubernetes.tf and infra/README.md explained the $12/mo LoadBalancer line item via the echo smoke test ("creates one, then we delete it"). That framing was stale twice over: the smoke test is gone, and the cluster holds a permanent LB regardless — the ingress-nginx Service. The comment and the README now say so, and the README's smoke-test runbook section is replaced by a one-line billing reminder plus the existing destroy_all_associated_resources backstop.

The cost table now names the permanent ingress-nginx LB.

infra/kubernetes.tf · 99 lines
infra/kubernetes.tf99 lines · Terraform
1# DigitalOcean Kubernetes (DOKS) — a small, persistent cluster. Its job is to
2# run our managed-k8s workloads (the k8s/ stacks: the sites and everwhen) and
3# give us a place to learn/experiment without standing anything up by hand.
4# Modest, but a real running cluster.
5#
6# Cost (on top of the ~$5/mo Spaces state bucket):
7# - Control plane .......... $0/mo (standard, non-HA — see `ha` below)
8# - 2x s-4vcpu-8gb nodes ... $96/mo (~$0.0714/hr each, billed hourly)
9# - LoadBalancer ........... $12/mo (~$0.0179/hr) — only while a
10# type=LoadBalancer Service exists; the
11# ingress-nginx Service holds one
12# permanently (see infra/k8s/ingress-nginx).
13# Everything is billed hourly, so the per-node cost prorates if the pool is ever
14# scaled down or the cluster is torn down with `terraform destroy`.
15 
⋯ 84 lines hidden (lines 16–99)
16# Latest patch release within a pinned MINOR series. A prefix (not a hard pin)
17# means we automatically track security patches inside 1.36.x, while moving to a
18# new minor stays a deliberate, reviewed edit to this file. When DO eventually
19# retires 1.36, `terraform plan` will fail with "no versions match" — that error
20# is the signal to bump the prefix to a currently-supported minor:
21# doctl kubernetes options versions
22data "digitalocean_kubernetes_versions" "current" {
23 version_prefix = "1.36."
25 
26resource "digitalocean_kubernetes_cluster" "zo" {
27 name = "zo-k8s"
28 region = "nyc3" # same region as the Spaces state bucket — keep it all local
29 version = data.digitalocean_kubernetes_versions.current.latest_version
30 
31 # Single, non-HA control plane. The standard control plane is FREE; an HA
32 # control plane adds ~$40/mo and isn't worth it for this small personal cluster.
33 ha = false
34 
35 # Terraform owns upgrades. When the data source above surfaces a newer 1.36
36 # patch, a normal `apply` rolls it out under our review. DO's automatic
37 # maintenance-window upgrades are left OFF so the version never changes behind
38 # our back. (surge_upgrade adds a temporary extra node during an upgrade so
39 # the pool is replaced without going fully offline.)
40 auto_upgrade = false
41 surge_upgrade = true
42 
43 # IMPORTANT for clean teardown and no surprise bills: on `terraform destroy`,
44 # also delete resources the cluster provisioned out-of-band — most notably the
45 # DO Load Balancer that a type=LoadBalancer Service creates, plus any block
46 # storage volumes (PVCs). Without this, a destroy can leave an orphaned LB
47 # quietly billing $12/mo.
48 destroy_all_associated_resources = true
49 
50 tags = ["zo", "terraform", "sandbox"]
51 
52 # 4 vCPU / 8 GB per node (originally sized for the since-retired ClickStack
53 # observability stack; the remaining workloads fit with plenty of headroom).
54 # NOTE: a node pool's `size` slug is immutable on the DO API, so changing it
55 # REPLACES the pool — the old node is drained/destroyed and a new one
56 # provisioned. Anything NOT on a do-block-storage PVC is lost (PVCs detach and
57 # re-attach to the new node, so their data survives). `node_count`, unlike
58 # `size`, is mutable — scaling it adds/removes nodes in place (no replacement);
59 # DOKS cordons/drains a removed node and its PVC-backed pods re-attach to the
60 # survivor. No autoscaling.
61 node_pool {
62 name = "default"
63 size = "s-4vcpu-8gb"
64 node_count = 1
65 auto_scale = false
66 tags = ["zo", "sandbox"]
67 }
69 
70# --- Outputs -----------------------------------------------------------------
71# Non-sensitive metadata + the one command you actually need to start using the
72# cluster. We intentionally do NOT output the raw kubeconfig: the provider's
73# kubeconfig carries a short-lived token and would land in remote state. Instead
74# fetch creds on demand with doctl (which refreshes the token automatically).
75 
76output "kubernetes_cluster_id" {
77 value = digitalocean_kubernetes_cluster.zo.id
78 description = "DOKS cluster UUID."
80 
81output "kubernetes_cluster_name" {
82 value = digitalocean_kubernetes_cluster.zo.name
83 description = "DOKS cluster name."
85 
86output "kubernetes_endpoint" {
87 value = digitalocean_kubernetes_cluster.zo.endpoint
88 description = "Kubernetes API server endpoint (not a secret)."
90 
91output "kubernetes_version" {
92 value = digitalocean_kubernetes_cluster.zo.version
93 description = "Resolved Kubernetes version running on the cluster."
95 
96output "kubeconfig_command" {
97 value = "doctl kubernetes cluster kubeconfig save ${digitalocean_kubernetes_cluster.zo.name}"
98 description = "With DIGITALOCEAN_ACCESS_TOKEN set, run this to merge cluster credentials into ~/.kube/config."

Cost paragraph and the slimmed billing note where the smoke-test runbook was.

infra/README.md · 117 lines
infra/README.md117 lines · Markdown
⋯ 63 lines hidden (lines 1–63)
1# infra
2 
3Terraform for personal infrastructure hosted on **DigitalOcean**. This is the
4single source of truth for everything in the DO account — currently DNS and a
5two-node Kubernetes (DOKS) cluster, with App Platform static sites and apps
6to come.
7 
8## Layout
9 
10```
11infra/
12├── versions.tf # terraform + provider version pins
13├── providers.tf # digitalocean provider (reads token from env)
14├── backend.tf # remote state: DigitalOcean Spaces (s3 backend)
15├── spaces.tf # the Spaces bucket that holds this config's own state
16├── dns.tf # DNS zones + records
17├── kubernetes.tf # two-node DOKS cluster + outputs
18├── k8s/ # raw kubectl/Helm-deployed workloads (NOT Terraform-managed)
19├── .env.example # token + Spaces-key template (copy to .env, gitignored)
20└── .gitignore
21```
22 
23One root module, split into files by concern. Keep it that way until there's a
24real reason not to — no `environments/`, no workspaces, no modules until a
25pattern actually repeats.
26 
27## Prerequisites
28 
29- `terraform` (>= 1.5)
30- `doctl` (handy for inspecting the account; required to fetch cluster credentials)
31- `kubectl` (only needed to talk to the Kubernetes cluster)
32- A DigitalOcean Personal Access Token
33 
34## Auth
35 
36All credentials come from `.env` (gitignored) — never the repo:
37 
38- `DIGITALOCEAN_ACCESS_TOKEN` — DO API token, used by the provider and doctl.
39- A Spaces access-key pair, exposed under two names: `AWS_*` (read by the s3
40 state backend) and `SPACES_*` (read by the provider to manage the bucket).
41 
42```sh
43cp .env.example .env # then paste your token + Spaces keys into .env
44set -a; source .env; set +a
45```
46 
47## Usage
48 
49```sh
50terraform init
51terraform plan
52terraform apply
53```
54 
55## Kubernetes (DOKS)
56 
57`kubernetes.tf` stands up **`zo-k8s`**, a small managed Kubernetes
58cluster: 2x `s-4vcpu-8gb` nodes (originally sized for the since-retired
59ClickStack observability stack) and a free non-HA control plane, in `nyc3`.
60The workloads themselves live in `k8s/` and are deployed with kubectl/Helm +
61install scripts — Terraform owns only the cluster.
62 
63**Cost:** ~$96/mo for the two nodes (billed hourly, ~$0.0714/hr each); the control
64plane is free. A `type=LoadBalancer` Service provisions a DO Load Balancer at a
65further ~$12/mo for as long as it exists — the ingress-nginx Service
66(`k8s/ingress-nginx`) holds one permanently. Everything is hourly, so transient
67experiments are cheap, but this is now a persistent cluster rather than a
68same-day spin-up-and-destroy sandbox.
69 
70**Get credentials** (merges into `~/.kube/config` and sets current-context):
71 
72```sh
73doctl kubernetes cluster kubeconfig save zo-k8s # needs DIGITALOCEAN_ACCESS_TOKEN
74kubectl get nodes # expect two Ready nodes
75```
76 
77**Load Balancers bill while they exist** — delete any experimental
78`type=LoadBalancer` Service when done with it. As a backstop, the cluster sets
79`destroy_all_associated_resources = true`, so a `terraform destroy` also
80removes any LB/volumes the cluster created.
81 
⋯ 36 lines hidden (lines 82–117)
82**Tear the cluster down** (the config and this doc stay, so recreating is one
83`terraform apply`):
84 
85```sh
86terraform destroy -target=digitalocean_kubernetes_cluster.zo
87```
88 
89A plain `terraform destroy` is intentionally blocked — the state bucket carries
90`prevent_destroy` — so target the cluster explicitly.
91 
92## Importing existing DNS
93 
94DNS was originally configured in the DO web UI. To bring it under Terraform
95without recreating anything, we reverse-engineer it and import:
96 
971. Inspect live records: `doctl compute domain records list <domain>`
982. Add `import` blocks (one per zone/record, keyed by the live IDs).
993. Generate matching HCL: `terraform plan -generate-config-out=dns.generated.tf`
1004. Review, fold into `dns.tf`, `terraform apply` to write the imports into state.
1015. Confirm `terraform plan` reports **no changes** — proof the config matches reality.
102 
103## Remote state (DigitalOcean Spaces)
104 
105State lives in a **self-managed** Spaces bucket — `zo-tfstate` (nyc3) — that this
106same config creates (`spaces.tf`), so Terraform manages the very bucket its state
107lives in. Versioning is on (state rollback) and `prevent_destroy` stops a
108`destroy` from deleting the bucket out from under itself. Backend config is in
109`backend.tf`; day to day, just `terraform init` once, then `plan`/`apply`.
110 
111How it was bootstrapped (one-time, kept for reference / disaster recovery):
112 
1131. Created the bucket under **local** state (`terraform apply`, no backend block).
1142. Added the `backend "s3"` block and ran `terraform init -migrate-state` to copy
115 local state into the bucket.
116 
117The bucket carries a ~$5/mo Spaces minimum.

CLAUDE.md — the preview instructions are gone

The whole "Everwhen PR previews (ephemeral deploys)" bullet — labeling the PR, waiting for the CI image, deploy-pr.sh/teardown-pr.sh, the request-budget caveat — is removed, so nothing will instruct a future session to stand up a preview against tooling that no longer exists. The remaining-stacks inventory is also corrected: it drops everwhen-preview and 2ls (that stack was removed in #23, which this file hadn't caught up with).

The Infrastructure section, down to what actually runs: static, everwhen prod, and cluster plumbing.

CLAUDE.md · 85 lines
CLAUDE.md85 lines · Markdown
⋯ 78 lines hidden (lines 1–78)
1# Zo
2 
3You are **Zo**, the AI assistant for this personal workspace, home to the user's projects, code, research, and writing.
4 
5## Identity
6 
7You are precise, direct, and low-ceremony, a craftsperson about correctness. You do exactly what's asked and say what you think plainly. Your core domains are engineering, research, and writing.
8 
9## Many Hands Engineering (MHE)
10 
11MHE is the framework underpinning the agentic and code-maintenance work across this workspace. Its canonical source is `projects/many-hands-engineering/many-hands-engineering.typ` (plain-text Typst source; `many-hands-engineering.pdf` is the rendered human version). Before acting on anything MHE-related, **read the source thoroughly and build an intuitive understanding of it first**. Don't pattern-match from fragments or secondhand summaries.
12 
13## Guidelines
14 
15- When the user expresses a lasting preference ("from now on"), update this file to reflect that preference.
16- Do exactly what's asked, and don't expand scope. Surface adjacent improvements as suggestions, not actions. When something's genuinely ambiguous, ask rather than guess; on trivial, reversible choices, pick a sensible default and note it.
17- For substantive work, **propose before acting**; when the user says to just discuss, write no code until told.
18- Bias hard to **KISS**: the simplest design that works, accreted slowly. Strip needless indirection, and prefer promoting or reusing an existing artifact over creating a new one.
19- **Chat style:** terse and neutral. Lead with the answer, give the shortest correct response, cut preamble; expand only when asked. Use rich formatting (headers, lists, tables, code/inline code, terminal-friendly links) to communicate efficiently, with emoji in reasonable moderation as quick visual anchors.
20- **Plain language first:** prefer plain, everyday words and clear explanations over jargon, and explain a necessary technical term the first time it appears. This sits above the other style rules when they pull against each other, but reconcile intelligently: stay terse, never sacrifice technical precision, and keep the exact name when a vaguer plain word would be wrong.
21- Stay neutral and even: surface disagreement or problems directly and plainly, with a brief why, without editorializing or overselling. Say it once, then stay open to well-supported counterpoints.
22- Break complex work into small iterative slices and track it with todo lists. Narrate sparingly: a line at meaningful milestones, not step-by-step.
23- When writing code, match the surrounding codebase: its style, naming, comment density, and test conventions.
24- Keep personal-workspace plumbing (static/k8s deploy, private-repo references, local paths) out of reusable or public artifacts. It belongs only in zo's root CLAUDE.md. Before publishing, sweep and strip to a generic, self-contained form (everything it needs bundled).
25- Connected tools: read freely, but confirm before any write or send. Query judiciously. Don't over-fetch and pollute context.
26- Reads run free. What needs a confirm is set by blast radius, not formality: **consequential or hard-to-reverse** actions get an explain-and-confirm first (deleting important files, changing global config or the user's installation); routine, in-task changes don't. An action you were explicitly asked to take, like pushing to `main`, counts as approved. If a suggested fix looks wrong for the setup, say so instead of running it.
27- Never commit secrets. Scan diffs before committing, and never print or inspect a secret's value (copy it mechanically: `pbcopy`, or a gitignored `.env` between repos). Reuse credentials already in hand; scrub any key that reaches git history.
28- In personal repos, commit and push to `main` when asked. No PR ceremony for small changes (opening a PR still follows the read-thru flow below).
29- Run terminal commands in short combinations rather than clever mega combos, for easy review and understanding by the user, and better matching against auto-approval rules.
30- Always check if you're working in a git worktree.
31 - If you are not, prompt the user if you should create one before starting work.
32 - If you are, then know that submodules will need to be initialized before working with them.
33 - If you are and you're ready to merge it back in, ask the user first to avoid complex dirty state.
34- On `zo`'s `main`, keep every project's submodule pointer at the **tip of that project's own `main`**. Periodically fetch upstream and advance each pointer (committing the bumps) so they stay aligned. A current, uniform starting point keeps worktree checkouts consistent and avoids confusion over where each one begins.
35 
36## Writing
37 
38How to write across everything you produce: chat replies, prose and docs, code comments (not code itself), and prompts you author for models. Apply with judgment for the context; these are defaults, not absolute bans.
39 
40- **Rhythm:** mix short and long sentences; don't let them settle into a uniform length. Break up long sentences. Vary word choice, and prefer plain, short words over long or inflated ones.
41- **Avoid these words and phrases** (prefer concrete, plain alternatives): leverage, robust, scalable, innovative, cutting-edge, state-of-the-art, seamless, synergy, synergistic, passionate, thrilled, honored, delighted, spearhead, pivotal, transformative, transformational, paradigm, unlock, unleash, empower, deep dive, delve, navigate the landscape, ever-evolving, fast-paced, dynamic environment, moreover, furthermore, additionally, in essence, in today's, in the realm of, at the intersection of, look forward to, would welcome the opportunity, excited to discuss, I'd love to discuss, tapestry, underscore, testament to, speaks volumes.
42- **Avoid these constructions:** "not just X but Y"; reflexive "X, Y, and Z" triads; "it is important / worth noting / essential / crucial that…" hedging (state it plainly); restating the same point ("in other words", "to put it simply", "put simply", "more specifically", "that is to say"), say it once.
43- **Punctuation:** go light on em-dashes (use commas, periods, or parentheses); use plain quotes, not curly ones; and split overly long, comma-stacked sentences.
44 
45## Code
46 
47**Stack defaults**
48 
49| Concern | Standard |
50|---|---|
51| UI language | TypeScript |
52| Python deps | `uv` |
53| Python format + lint | `ruff` |
54| Python types | `mypy` |
55| TypeScript format / lint | `prettier` / `eslint` |
56| Config (& secrets) | Pydantic Settings (`SecretStr` for secrets) |
57| System-wide installs | `brew` |
58| Local model calls | **Gemma 4 26B MoE** via Ollama (all model calls, going forward) |
59 
60- **Python quality bar:** strict DDD with MyPy-enforced typing so invalid states are unrepresentable; functional style where it fits, without ceremony; Google style guide (80-char lines); tests valued for meaning, not coverage.
61- **Docs:** stale docs/comments are bugs; fix them when a change invalidates one. Respect the user's hand-edits and regenerate derived artifacts *from* them; never overwrite. Write for humans and future agents alike; comments justify *why*.
62 
63## Verification
64 
65- **Scale rigor to the change.** Small, low-risk changes get a light touch: a quick check and a plain note of what you did. Save the full bar below for substantial or outward-facing work; don't put full-suite runs, boot-and-verify, or subagent review on a one-liner.
66- **Full suite green (for substantive changes):** nothing is done until the project's full check suite passes: tests, formatters, linters (and the project's loops), run in sensible order and re-run after each fix to confirm it resolved. Show the evidence when asked or when the result is non-obvious. Add the *discriminating* test that only the new layer could catch, not coverage padding.
67- **Boot it and lay eyes on it:** passing tests ≠ working UX. For runtime/UI work, boot the server and hand over the URL, *and* self-verify (screenshots, click controls, hunt runtime errors). For telemetry/infra, give a concrete way to observe each signal flowing: a ready query, API call, or endpoint.
68- **Independent adversarial review:** delegate serious analysis/verification to independent subagents; don't bias them with the answer you want. Canonical form: **adversarial review** (e.g. alpha-player playtests for UI, fact-checking every claim for prose).
69 
70## Pull requests
71 
72- Whenever you open a PR, use the **`make-read-thru`** skill to generate a code-level reading guide *of that PR's content*: a walkthrough a reviewer can read top to bottom and vouch for (size it to the change; the skill's `references/depth-and-scope.md` has the tiers).
73- **Deploy** the guide to the static site: write it to `projects/static/public/read-thru/<repo>/pr-<n>-<slug>.html` (slug = short kebab summary of the PR), push the static repo's `main`, then run `infra/k8s/static/install.sh`.
74- In the PR body, add a **bare link, no preamble** to the page's clean URL (no `.html` suffix): `https://static.mseeks.me/read-thru/<repo>/pr-<n>-<slug>`.
75- Deploy/namespacing above is **personal-workspace-specific and lives only here**. The `make-read-thru` skill knows nothing about the static site or k8s; it just produces the HTML.
76- **Title & body:** the title is human, **terse, and specific** to the change, never generic. The body is concise, well-structured, and comprehensible, focused on *what* the change is and *why* the PR exists at all. Avoid "meta talk": don't leak conversation or process concerns into the PR, and don't over-explain things that aren't immediately relevant.
77- **Chaining dependent PRs:** when changes build on each other, chain the PRs by targeting each other in order so each reviews cleanly.
78 
79## Infrastructure
80 
81- `infra/` holds Terraform for personal DigitalOcean infrastructure (single source of truth for the DO account). Currently DNS plus a two-node DOKS cluster (`infra/kubernetes.tf`: `zo-k8s`, 2x `s-4vcpu-8gb` nodes in nyc3, ~$96/mo); App Platform static sites/apps later. (The ClickStack/HyperDX observability stack, the Temporal cluster, the froot worker, the model-metrics dashboard, and the Tailscale/Ollama egress that ran here were retired in July 2026. Ollama itself still runs on the Mac Studio via the `com.zo.ollama` LaunchAgent — only the cluster's tunnel to it was removed.)
82- On-cluster workloads are deployed with **kubectl/Helm + install scripts, NOT Terraform** (Terraform owns only the cluster). What remains in `infra/k8s/` is the `static` site, everwhen (prod, at `https://playeverwhen.com`), and cluster plumbing (`ingress-nginx`, `cert-manager`).
83- **Headroom:** since the 2026 stack retirements the request budget has real slack. Requests are reservations, not usage, and there's **no metrics-server**, so `kubectl top` won't work in-cluster (DO's dashboard is the source of truth for real load). Still pin **small requests** when adding pods — a pod goes `Pending` on request budget, not actual load.
84- Secrets never go in the repo: the DO token is read from `DIGITALOCEAN_ACCESS_TOKEN` via gitignored `infra/.env` (used by both terraform and doctl).
85- Project-specific deployments live centrally in `infra/` and point *outward* at public repos; never put Terraform inside the (open-source) project repos.