What changed, and why

Same move as glassbox (zo#20), second verse. 2ls.tech leaves the zo-k8s cluster for Vercel, and Terraform keeps only what it already owned: the DNS zone. The apex A record re-points from the ingress load balancer to Vercel's anycast address, the www record flips from A to CNAME, and the infra/k8s/2ls stack is retired. As before there is deliberately no vercel provider. The project and both domains live in the Vercel dashboard. Deploys ride the GitHub integration. Terraform stays out of it.

The zone is more than the website. Its iCloud-mail and verification records (two MX, SPF, apple-domain, google-site-verification, DKIM) are untouched by this PR — only the two records that pointed at the cluster move.

The app-repo half is mseeks/2ls.tech#1.

dns.tf: apex re-pointed in place, www replaced

The two records part ways mechanically. The apex stays an A record, so its change is an in-place update: value and TTL, nothing destroyed, no gap of any kind. www is different. It changes type, and a type change forces replacement. Both records trade the live-LB data-source lookup for fixed values, and both TTLs rise from 300 to 3600, since the targets no longer churn when the LB is recreated.

The rewritten zone tail: comment, apex A, www CNAME. The mail records above line 288 are untouched.

infra/dns.tf · 405 lines
infra/dns.tf405 lines · Terraform
⋯ 287 lines hidden (lines 1–287)
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, froot, models) 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# *.everwhen.mseeks.me -> the same zo-k8s ingress Load Balancer. Wildcard record
130# so ephemeral per-PR preview hosts (pr-<N>.everwhen.mseeks.me; see
131# infra/k8s/everwhen-preview) resolve to the LB without a per-PR DNS change. The
132# bare everwhen.mseeks.me A record is gone — the Everwhen app moved to
133# playeverwhen.com — so this wildcard now serves per-PR previews only (it never
134# matched the bare apex anyway; a wildcard covers deeper labels, not the name
135# itself). cert-manager issues a per-host Let's Encrypt cert on first request
136# (HTTP-01), so each preview gets its own TLS with no wildcard cert needed. Same
137# read-only LB-IP sourcing as the records above — no hardcoded address to chase.
138resource "digitalocean_record" "mseeks_me_everwhen_wildcard" {
139 domain = digitalocean_domain.mseeks_me.name
140 type = "A"
141 name = "*.everwhen"
142 value = data.digitalocean_loadbalancer.ingress.ip
143 # Short TTL for the same reason as the others: the target is a DO LB whose IP
144 # can change if the LB is recreated.
145 ttl = 300
147 
148# froot.mseeks.me -> the same zo-k8s ingress Load Balancer. Public entrypoint
149# for froot's read-only read-model dashboard (https://froot.mseeks.me; see
150# infra/k8s/froot), gated by HTTP basic auth, and the host cert-manager gets a
151# Let's Encrypt cert for. Same read-only LB-IP sourcing as the records above —
152# no hardcoded address to chase.
153resource "digitalocean_record" "mseeks_me_froot" {
154 domain = digitalocean_domain.mseeks_me.name
155 type = "A"
156 name = "froot"
157 value = data.digitalocean_loadbalancer.ingress.ip
158 # Short TTL for the same reason as the others: the target is a DO LB whose IP
159 # can change if the LB is recreated.
160 ttl = 300
162 
163# models.mseeks.me -> the same zo-k8s ingress Load Balancer. Public entrypoint
164# for the read-only model-traffic dashboard (https://models.mseeks.me; see
165# infra/k8s/model-metrics), gated by HTTP basic auth, and the host cert-manager
166# gets a Let's Encrypt cert for. Same read-only LB-IP sourcing as the others.
167resource "digitalocean_record" "mseeks_me_models" {
168 domain = digitalocean_domain.mseeks_me.name
169 type = "A"
170 name = "models"
171 value = data.digitalocean_loadbalancer.ingress.ip
172 ttl = 300
174 
175# ============================== msull92.com ==============================
176 
177resource "digitalocean_domain" "msull92_com" {
178 name = "msull92.com"
180 
181resource "digitalocean_record" "msull92_com_mx_01" {
182 domain = digitalocean_domain.msull92_com.name
183 type = "MX"
184 name = "@"
185 value = "mx01.mail.icloud.com."
186 priority = 10
187 ttl = 14400
189 
190resource "digitalocean_record" "msull92_com_mx_02" {
191 domain = digitalocean_domain.msull92_com.name
192 type = "MX"
193 name = "@"
194 value = "mx02.mail.icloud.com."
195 priority = 10
196 ttl = 14400
198 
199resource "digitalocean_record" "msull92_com_apple_domain" {
200 domain = digitalocean_domain.msull92_com.name
201 type = "TXT"
202 name = "@"
203 value = "apple-domain=SLKQpEYsouHgb3mI"
204 ttl = 3600
206 
207resource "digitalocean_record" "msull92_com_spf" {
208 domain = digitalocean_domain.msull92_com.name
209 type = "TXT"
210 name = "@"
211 value = "\"v=spf1 include:icloud.com ~all\""
212 ttl = 3600
214 
215resource "digitalocean_record" "msull92_com_dkim" {
216 domain = digitalocean_domain.msull92_com.name
217 type = "CNAME"
218 name = "sig1._domainkey"
219 value = "sig1.dkim.msull92.com.at.icloudmailadmin.com."
220 ttl = 43200
222 
223# ============================== 2ls.tech ==============================
224# HCL resource names can't start with a digit, so these use a `_2ls_tech`
225# prefix (a leading underscore is valid) rather than the plain `2ls_tech`
226# the mseeks_me/msull92_com convention would suggest.
227 
228resource "digitalocean_domain" "_2ls_tech" {
229 name = "2ls.tech"
231 
232resource "digitalocean_record" "_2ls_tech_mx_01" {
233 domain = digitalocean_domain._2ls_tech.name
234 type = "MX"
235 name = "@"
236 value = "mx01.mail.icloud.com."
237 priority = 10
238 ttl = 14400
240 
241resource "digitalocean_record" "_2ls_tech_mx_02" {
242 domain = digitalocean_domain._2ls_tech.name
243 type = "MX"
244 name = "@"
245 value = "mx02.mail.icloud.com."
246 priority = 10
247 ttl = 14400
249 
250resource "digitalocean_record" "_2ls_tech_apple_domain" {
251 domain = digitalocean_domain._2ls_tech.name
252 type = "TXT"
253 name = "@"
254 value = "apple-domain=KAYDndJUw5TrEwCe"
255 ttl = 3600
257 
258# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
259# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
260# itself (see the SPF quoting gotcha at the top of this file).
261resource "digitalocean_record" "_2ls_tech_google_site_verification" {
262 domain = digitalocean_domain._2ls_tech.name
263 type = "TXT"
264 name = "@"
265 value = "google-site-verification=9e1Dm56-fdkP2jTv1pGNI5II2ol0pR6Xtw8RtS1gXwM"
266 ttl = 3600
268 
269# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
270# file. Recreated once (terraform apply -replace) to strip the doubly-quoted
271# `""v=spf1 ...""` the original escaped-quote value produced on create.
272resource "digitalocean_record" "_2ls_tech_spf" {
273 domain = digitalocean_domain._2ls_tech.name
274 type = "TXT"
275 name = "@"
276 value = "v=spf1 include:icloud.com ~all"
277 ttl = 3600
279 
280resource "digitalocean_record" "_2ls_tech_dkim" {
281 domain = digitalocean_domain._2ls_tech.name
282 type = "CNAME"
283 name = "sig1._domainkey"
284 value = "sig1.dkim.2ls.tech.at.icloudmailadmin.com."
285 ttl = 43200
287 
288# 2ls.tech (+ www) -> Vercel. The Two Ls company site is hosted on Vercel, not
289# the cluster: the project + both domains are managed in the Vercel dashboard
290# and deploys ride its GitHub integration (push to main in mseeks/2ls.tech).
291# Vercel terminates TLS at its edge (no cert-manager), serves the apex, and
292# redirects www to it. The mail/verification records above are unaffected.
294# Values: both are this project's own targets, issued by the dashboard when
295# the domains were added (Vercel mints per-project values now; the universal
296# 76.76.21.21 / cname.vercel-dns.com are legacy). If the Vercel project is
297# ever deleted and recreated it gets new targets — read them off the
298# project's Domains tab, and only re-point these records once the domains
299# are attached there.
300resource "digitalocean_record" "_2ls_tech_apex" {
301 domain = digitalocean_domain._2ls_tech.name
302 type = "A"
303 name = "@"
304 value = "216.198.79.1"
305 # Stable Vercel target (unlike the LB IP this used to track), so no
306 # short-TTL churn guard is needed.
307 ttl = 3600
309 
310resource "digitalocean_record" "_2ls_tech_www" {
311 domain = digitalocean_domain._2ls_tech.name
312 type = "CNAME"
313 name = "www"
314 value = "bae588b1e31b34e9.vercel-dns-017.com."
⋯ 91 lines hidden (lines 315–405)
315 ttl = 3600
317 
318# ============================== playeverwhen.com ==============================
319 
320resource "digitalocean_domain" "playeverwhen_com" {
321 name = "playeverwhen.com"
323 
324resource "digitalocean_record" "playeverwhen_com_mx_01" {
325 domain = digitalocean_domain.playeverwhen_com.name
326 type = "MX"
327 name = "@"
328 value = "mx01.mail.icloud.com."
329 priority = 10
330 ttl = 14400
332 
333resource "digitalocean_record" "playeverwhen_com_mx_02" {
334 domain = digitalocean_domain.playeverwhen_com.name
335 type = "MX"
336 name = "@"
337 value = "mx02.mail.icloud.com."
338 priority = 10
339 ttl = 14400
341 
342resource "digitalocean_record" "playeverwhen_com_apple_domain" {
343 domain = digitalocean_domain.playeverwhen_com.name
344 type = "TXT"
345 name = "@"
346 value = "apple-domain=DEESFXkRhJ4wE6Lm"
347 ttl = 3600
349 
350# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
351# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
352# itself (see the SPF quoting gotcha at the top of this file).
353resource "digitalocean_record" "playeverwhen_com_google_site_verification" {
354 domain = digitalocean_domain.playeverwhen_com.name
355 type = "TXT"
356 name = "@"
357 value = "google-site-verification=1FSmsnv2MPG_mKoCrdlSABXfyXFTaeVBETNRmLNi85o"
358 ttl = 3600
360 
361# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
362# file. DigitalOcean adds the DNS wire quotes itself.
363resource "digitalocean_record" "playeverwhen_com_spf" {
364 domain = digitalocean_domain.playeverwhen_com.name
365 type = "TXT"
366 name = "@"
367 value = "v=spf1 include:icloud.com ~all"
368 ttl = 3600
370 
371resource "digitalocean_record" "playeverwhen_com_dkim" {
372 domain = digitalocean_domain.playeverwhen_com.name
373 type = "CNAME"
374 name = "sig1._domainkey"
375 value = "sig1.dkim.playeverwhen.com.at.icloudmailadmin.com."
376 ttl = 43200
378 
379# playeverwhen.com (apex) -> the zo-k8s ingress Load Balancer. Public entrypoint
380# for the Everwhen SSR app (https://playeverwhen.com; see infra/k8s/everwhen),
381# migrated here from everwhen.mseeks.me. cert-manager gets a Let's Encrypt cert
382# for it (HTTP-01, so the apex must resolve to the LB). An apex A record (a CNAME
383# is illegal at a zone apex) works because the value is the LB IP, sourced
384# read-only from data.digitalocean_loadbalancer.ingress — no hardcoded address to
385# chase. Coexists fine with the iCloud MX/TXT records above.
386resource "digitalocean_record" "playeverwhen_com_apex" {
387 domain = digitalocean_domain.playeverwhen_com.name
388 type = "A"
389 name = "@"
390 value = data.digitalocean_loadbalancer.ingress.ip
391 # Short TTL: the target is a DO LB whose IP can change if the LB is recreated.
392 ttl = 300
394 
395# www.playeverwhen.com -> the same LB. Serves only a 301 redirect to the apex
396# (see the everwhen-www-redirect Ingress in infra/k8s/everwhen); cert-manager
397# issues its own cert so the redirect is reachable over HTTPS. Same read-only
398# LB-IP sourcing as the apex.
399resource "digitalocean_record" "playeverwhen_com_www" {
400 domain = digitalocean_domain.playeverwhen_com.name
401 type = "A"
402 name = "www"
403 value = data.digitalocean_loadbalancer.ingress.ip
404 ttl = 300

The k8s stack, retired

Five files under infra/k8s/2ls/ disappear. The census reads the same as it did for glassbox, one directory earlier:

filewhat it didreplaced by
manifests/00-namespace.yamlthe 2ls namespacenothing to isolate
manifests/10-deployment.yamlone nginx pod serving ghcr.io/mseeks/2ls.tech:latest and its ServiceVercel's edge serves public/
manifests/20-ingress.yamlrouted both hosts through the DO LB; cert-manager filled 2ls-tech-tls (one cert, both names) via letsencrypt-prodVercel terminates TLS itself
install.shcontext preflight, kubectl apply, rollout restart + status waitgit push — the integration deploys
README.mdhow the stack was wiredthe app repo's Serving section

One doc reference rides along: the everwhen README compared itself to "static/2ls (prebuilt static bundles)", and with 2ls gone the comparison slims to static alone.

Verification, and the cutover runbook

terraform fmt -check, init, and validate pass, and a plan targeted at the two records shows exactly their changes and nothing else:

_2ls_tech_apex will be updated in-place — value: "174.138.116.75" -> "216.198.79.1", ttl: 300 -> 3600. _2ls_tech_www must be replaced — type: "A" -> "CNAME" (forces replacement), value -> "bae588b1e31b34e9.vercel-dns-017.com.". Plan: 1 to add, 1 to change, 1 to destroy.

The untargeted plan still carries the known unrelated drift (the DOKS 1.36.0-do.2 patch bump), which stays excluded here exactly as it was for glassbox.

The cutover, in order:

1. Create the Vercel project (import mseeks/2ls.tech; vercel.json already names public/ as the output, so no dashboard build config), let it deploy, then add both domains and set www to redirect to the apex via the dashboard's Redirect-to setting (a config choice, not automatic; apex as canonical matches playeverwhen.com). The dashboard issued per-project DNS values at that moment; the committed ones are what it issued. 2. Merge both PRs; apply the two targeted record changes. 3. Verify: apex serves the page from Vercel, www redirects to the apex, /site.webmanifest keeps its MIME type, /index.html 308s to /. 4. kubectl delete namespace 2ls, then prune branches and the ghcr package.