Context
www.etiennerobert.com and apex etiennerobert.com are currently on Vercel.
Every other subdomain (creatures/files/adele/umami) is served from tower's
Caddy. Moving these last two to tower removes the final third-party hosting
dependency for the domain.
The test.etiennerobert.com vhost was added earlier as scaffolding to prove
the etiennerobert.com flake's build output served correctly through tower's
Caddy. It is not a permanent fixture — once production cuts over, it
gets torn down along with the Vercel project.
The site's flake (github:etrobert/etiennerobert.com) already exposes:
packages.default — the static build (consumed by the existing
test.etiennerobert.com vhost today)
nixosModules.default — stub Caddy vhost for http://etiennerobert.com
with a TODO to make it HTTPS; not currently imported on tower
This issue plans the migration as two stacked PRs and a manual DNS cutover,
plus a teardown phase that removes both Vercel and test.etiennerobert.com.
PR 1 — Stage /stats ad-blocker-resistant proxy on test.etiennerobert.com
Repo: etrobert/setup
Extend the existing test.etiennerobert.com Caddy block in
modules/server.nix to add a handle_path /stats/* block that
reverse-proxies to https://umami.etiennerobert.com (with a
header_up Host umami.etiennerobert.com line so umami's vhost match works).
This mirrors the Vercel vercel.json rewrites currently in production, on a
hostname that doesn't carry traffic — pure shake-down of the Caddy config.
Verify:
curl https://test.etiennerobert.com/stats/script.js → 200,
application/javascript, ~4595 bytes (same payload as direct umami)
curl -X POST https://test.etiennerobert.com/stats/api/send -H 'content-type: application/json' -d '{"type":"event"}'
→ reaches umami (200/400-class, not a Caddy 502 or 404)
- No
Host header confusion in journalctl -u caddy on tower
This is throwaway test-bed work; the block goes away in the cleanup phase.
PR 2 — Production vhosts via flake nixosModules.default
Repo: etrobert/etiennerobert.com
Promote nixosModules.default from stub to production-ready. Design choices:
- No
enable option. This flake's only consumer is tower; importing the
module is the activation. Adding enable would be ceremony with no
payoff (the nixpkgs convention exists to guard against thousands of
modules accidentally activating; here there's exactly one).
- No
domain option. Hostnames are part of the module's identity, not a
tunable — the module's job is specifically "host www.etiennerobert.com
and redirect etiennerobert.com to it."
- One option:
umamiUpstream. Default https://umami.etiennerobert.com
(works from any host that can reach the public URL). Tower overrides to
http://localhost:3001 to skip a TLS hop and the DNS lookup when umami
runs locally. header_up Host umami.etiennerobert.com is hardcoded; it's
the correct value for both upstream modes (umami at :3001 doesn't
vhost-match on Host, so the header is harmless there).
Sketch:
{ self }: { config, lib, pkgs, ... }:
let cfg = config.services.etiennerobertCom; in {
options.services.etiennerobertCom.umamiUpstream = lib.mkOption {
type = lib.types.str;
default = "https://umami.etiennerobert.com";
example = "http://localhost:3001";
description = ''
reverse_proxy target for /stats/* requests. Default works from
any host. Override to a same-host URL when umami runs locally.
'';
};
config.services.caddy.virtualHosts = {
"www.etiennerobert.com".extraConfig = ''
encode zstd gzip
handle_path /stats/* {
reverse_proxy ${cfg.umamiUpstream} {
header_up Host umami.etiennerobert.com
}
}
handle {
root * ${self.packages.${pkgs.stdenv.hostPlatform.system}.default}
try_files {path} /index.html
file_server
}
'';
"etiennerobert.com".extraConfig = ''
redir https://www.etiennerobert.com{uri} 308
'';
};
}
Drop the TODO http comment.
Repo: etrobert/setup
flake.nix: bump etiennerobert-com input to the new commit.
- Tower's host config: import
etiennerobert-com.nixosModules.default and
set services.etiennerobertCom.umamiUpstream = "http://localhost:3001";
modules/server.nix: add @ and www to services.ddclient.domains.
modules/lan-dns.nix: add two host-record entries:
"www.etiennerobert.com,192.168.0.130"
"etiennerobert.com,192.168.0.130"
Verify before DNS flip (still resolving to Vercel publicly):
nix eval .#nixosConfigurations.tower.config.system.build.toplevel.drvPath
succeeds — module assertions OK
nix flake check (statix + deadnix) clean
Manual operator steps (not in any PR)
After PR 2 merges and tower is rebuilt:
- Verify Namecheap has A records for
@ and www. Both should already
exist (currently pointing at Vercel's IPs). If either is missing for some
reason, create a placeholder — Namecheap's dynamic DNS protocol can only
update existing records, not create them (same gotcha hit with the
umami subdomain).
- Trigger the ddclient timer (
sudo systemctl start ddclient on tower)
to push the WAN IP 91.64.99.245 to both records.
- Wait for propagation + Caddy ACME. Watch
journalctl -u caddy -f —
within ~1 min of DNS resolving correctly, certs for
www.etiennerobert.com and etiennerobert.com should be obtained.
- Verify externally:
https://www.etiennerobert.com/ → 200, content from tower
https://etiennerobert.com/ → 308 to www variant
https://www.etiennerobert.com/stats/script.js → 200
- Load in browser with uBlock on; new event appears in umami dashboard
- Rollback path if Caddy can't obtain certs: revert the
@ and www
records at Namecheap to Vercel's IPs. The setup repo changes are harmless
on tower without working DNS — the vhosts just fail to acquire certs and
serve nothing for those names. test.etiennerobert.com keeps working
independently as a known-good fallback for confirming Caddy/umami
plumbing on tower while the production vhost is broken.
Cleanup (final tracked step)
Once tower has served www.etiennerobert.com cleanly for a week (no cert
renewal blips, no surprise visitors with broken setups):
Tear down Vercel:
- Delete
vercel.json from etrobert/etiennerobert.com — the rewrites
are now in Caddy, not Vercel.
- Decommission the Vercel project at vercel.com.
Tear down test.etiennerobert.com:
modules/server.nix: delete the test.etiennerobert.com virtualHost
block (including the /stats proxy added in PR 1).
modules/server.nix: remove "test" from services.ddclient.domains.
modules/lan-dns.nix: remove the "test.etiennerobert.com,192.168.0.130"
host-record entry.
- Optionally delete the
test A record at Namecheap — once it's out of
ddclient it just won't update; harmless either way.
Out of scope
Context
www.etiennerobert.comand apexetiennerobert.comare currently on Vercel.Every other subdomain (
creatures/files/adele/umami) is served from tower'sCaddy. Moving these last two to tower removes the final third-party hosting
dependency for the domain.
The
test.etiennerobert.comvhost was added earlier as scaffolding to provethe etiennerobert.com flake's build output served correctly through tower's
Caddy. It is not a permanent fixture — once production cuts over, it
gets torn down along with the Vercel project.
The site's flake (
github:etrobert/etiennerobert.com) already exposes:packages.default— the static build (consumed by the existingtest.etiennerobert.comvhost today)nixosModules.default— stub Caddy vhost forhttp://etiennerobert.comwith a TODO to make it HTTPS; not currently imported on tower
This issue plans the migration as two stacked PRs and a manual DNS cutover,
plus a teardown phase that removes both Vercel and
test.etiennerobert.com.PR 1 — Stage
/statsad-blocker-resistant proxy ontest.etiennerobert.comRepo:
etrobert/setupExtend the existing
test.etiennerobert.comCaddy block inmodules/server.nixto add ahandle_path /stats/*block thatreverse-proxies to
https://umami.etiennerobert.com(with aheader_up Host umami.etiennerobert.comline so umami's vhost match works).This mirrors the Vercel
vercel.jsonrewrites currently in production, on ahostname that doesn't carry traffic — pure shake-down of the Caddy config.
Verify:
curl https://test.etiennerobert.com/stats/script.js→ 200,application/javascript, ~4595 bytes (same payload as direct umami)curl -X POST https://test.etiennerobert.com/stats/api/send -H 'content-type: application/json' -d '{"type":"event"}'→ reaches umami (200/400-class, not a Caddy 502 or 404)
Hostheader confusion injournalctl -u caddyon towerThis is throwaway test-bed work; the block goes away in the cleanup phase.
PR 2 — Production vhosts via flake
nixosModules.defaultRepo:
etrobert/etiennerobert.comPromote
nixosModules.defaultfrom stub to production-ready. Design choices:enableoption. This flake's only consumer is tower; importing themodule is the activation. Adding
enablewould be ceremony with nopayoff (the nixpkgs convention exists to guard against thousands of
modules accidentally activating; here there's exactly one).
domainoption. Hostnames are part of the module's identity, not atunable — the module's job is specifically "host
www.etiennerobert.comand redirect
etiennerobert.comto it."umamiUpstream. Defaulthttps://umami.etiennerobert.com(works from any host that can reach the public URL). Tower overrides to
http://localhost:3001to skip a TLS hop and the DNS lookup when umamiruns locally.
header_up Host umami.etiennerobert.comis hardcoded; it'sthe correct value for both upstream modes (umami at
:3001doesn'tvhost-match on Host, so the header is harmless there).
Sketch:
Drop the
TODO httpcomment.Repo:
etrobert/setupflake.nix: bumpetiennerobert-cominput to the new commit.etiennerobert-com.nixosModules.defaultandset
services.etiennerobertCom.umamiUpstream = "http://localhost:3001";modules/server.nix: add@andwwwtoservices.ddclient.domains.modules/lan-dns.nix: add twohost-recordentries:"www.etiennerobert.com,192.168.0.130""etiennerobert.com,192.168.0.130"Verify before DNS flip (still resolving to Vercel publicly):
nix eval .#nixosConfigurations.tower.config.system.build.toplevel.drvPathsucceeds — module assertions OK
nix flake check(statix + deadnix) cleanManual operator steps (not in any PR)
After PR 2 merges and tower is rebuilt:
@andwww. Both should alreadyexist (currently pointing at Vercel's IPs). If either is missing for some
reason, create a placeholder — Namecheap's dynamic DNS protocol can only
update existing records, not create them (same gotcha hit with the
umamisubdomain).sudo systemctl start ddclienton tower)to push the WAN IP
91.64.99.245to both records.journalctl -u caddy -f—within ~1 min of DNS resolving correctly, certs for
www.etiennerobert.comandetiennerobert.comshould be obtained.https://www.etiennerobert.com/→ 200, content from towerhttps://etiennerobert.com/→ 308 to www varianthttps://www.etiennerobert.com/stats/script.js→ 200@andwwwrecords at Namecheap to Vercel's IPs. The setup repo changes are harmless
on tower without working DNS — the vhosts just fail to acquire certs and
serve nothing for those names.
test.etiennerobert.comkeeps workingindependently as a known-good fallback for confirming Caddy/umami
plumbing on tower while the production vhost is broken.
Cleanup (final tracked step)
Once tower has served
www.etiennerobert.comcleanly for a week (no certrenewal blips, no surprise visitors with broken setups):
Tear down Vercel:
vercel.jsonfrometrobert/etiennerobert.com— the rewritesare now in Caddy, not Vercel.
Tear down
test.etiennerobert.com:modules/server.nix: delete thetest.etiennerobert.comvirtualHostblock (including the
/statsproxy added in PR 1).modules/server.nix: remove"test"fromservices.ddclient.domains.modules/lan-dns.nix: remove the"test.etiennerobert.com,192.168.0.130"host-record entry.
testA record at Namecheap — once it's out ofddclient it just won't update; harmless either way.
Out of scope
umami.etiennerobert.comis already inmodules/lan-dns.nix(merged via feat(tower): deploy umami analytics service #155) and will appear on pi at the nextnightly auto-rebuild.
tracked separately under PR feat: IPv6 RA on pi for guest-friendly split-horizon DNS #140 and applies equally to all tower-hosted
services. Not blocking this migration.