From 93308e15ab9b971e0a841c697897b3dcec973605 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 19:18:12 +0200 Subject: [PATCH] Add change explanation: personal-website-edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive walkthrough (Background → Intuition → Code → Quiz) of the Gitea subdomain move, the Caddyfile snippet-import refactor, and the admin-off fix, with a micro-world simulating the config-clobber bug. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KG7yQj4hP8DDpasK2e4cn7 --- ...-13-explanation-personal-website-edge.html | 487 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-13-explanation-personal-website-edge.html diff --git a/docs/explanations/2026-07-13-explanation-personal-website-edge.html b/docs/explanations/2026-07-13-explanation-personal-website-edge.html new file mode 100644 index 00000000..a884b474 --- /dev/null +++ b/docs/explanations/2026-07-13-explanation-personal-website-edge.html @@ -0,0 +1,487 @@ +

One domain, one edge, and the day two Caddies fought over it

+ +

+ How a personal website took the root of kuhy.duckdns.org — moving + Gitea aside, teaching a shared reverse proxy to stop overwriting itself, and + keeping a second Caddy from stealing the ports. +

+ + + +
+

1 · Background — one server, many front doors

+ +

+ This home server hosts several things at once: a self-hosted + Gitea (a GitHub-like git host), a dufs + file cloud, a SyncYomi manga-sync service — and now a + personal website. They all share a single public IP and a single pair of + open ports (80/443). +

+ +

+ The trick that lets one IP serve many sites is a reverse + proxy. One program — here, a Caddy container + called gitea-caddy — owns ports 80/443, terminates HTTPS, and + routes each incoming request to the right backend based on the + hostname the visitor asked for: +

+ +
+
+
🌐 Internet
request for a hostname
+
→
+
+ gitea-caddy + owns :80 / :443
auto-HTTPS
+
+
→
+
+
gitea → 127.0.0.1:3000
+
dufs → 127.0.0.1:5000
+
syncyomi → 127.0.0.1:8282
+
+
+
+ +

+ Caddy reads a config file, the Caddyfile. Each service is + one block: a hostname, then where to send its traffic. +

+ +
kuhy.duckdns.org      { reverse_proxy 127.0.0.1:3000 }   # Gitea (the root!)
+kuhy-cloud.duckdns.org{ reverse_proxy 127.0.0.1:5000 }   # dufs
+kuhy-sync.duckdns.org { reverse_proxy 127.0.0.1:8282 }   # SyncYomi
+ +

+ Two details matter for this change. First, the bare root + kuhy.duckdns.org was Gitea's — and the goal + was to give that root to a new website. Second, every container runs with + network_mode: host: instead of Docker's private network, each + container shares the host's network directly. (The home firewall drops + Docker's normal bridge forwarding, so host networking is the reliable way to + reach the internet for TLS renewals.) Host networking will come back to bite + us in section 2. +

+ +

+ Finally, that Caddyfile isn't hand-written — it's generated by + setup_gitea.sh. SyncYomi and dufs each appended their + block to the same file from their own setup scripts. Hold that thought. +

+
+ +
+

2 · Intuition — two ways a shared edge bites back

+ +

Problem A — the self-overwriting config file

+

+ setup_gitea.sh is meant to be safe to re-run. But its way of + writing the Caddyfile was cat > Caddyfile — it + regenerated the whole file from scratch, containing only Gitea's + block. dufs and SyncYomi had appended their blocks later. + So every time you re-ran the Gitea setup, those blocks silently vanished and + two live services went dark. A latent trap, waiting for the next re-run. +

+ +

+ The fix is the shape of the change: instead of one big file that one script + owns, the root Caddyfile becomes a single line — + import /etc/caddy/sites/*.caddy — and each service owns its + own snippet file in a sites/ folder. Now re-running any one + setup script only touches its own snippet. Nothing can clobber a neighbour. +

+ +
+

Play: re-run the Gitea setup and watch what survives.

+
+ + +
+
+
+ + +
+

All four services are up. Now re-run the Gitea setup…

+
+ +

Problem B — two Caddies, one admin port

+

+ The website is served by a second tiny Caddy container, listening + only on loopback 127.0.0.1:8088, with + gitea-caddy proxying the root domain to it. But both Caddies use + host networking — so they share every port on the host, including + Caddy's admin API on :2019. When the deploy ran + caddy reload against gitea-caddy, the reload was + delivered over :2019… and sometimes landed on the + website's Caddy instead. That Caddy then adopted the edge config, + grabbed 80/443, started failing to issue certificates, and abandoned 8088 — + the site went blank. +

+ +
+
+
reload → :2019
+
↘ ↙
+
+
gitea-caddy
should receive it
+
website caddy
must NOT — admin off
+
+
+
+ +

+ The one-word fix: give the website's Caddy admin off. With no + admin endpoint, it can never be reconfigured from outside — it stays a plain + static server on 8088, and reloads only reach gitea-caddy. +

+
+ +
+

3 · Code — the change, by concern

+ +

a. Move Gitea off the root

+

+ One constant flips. It flows into Gitea's DOMAIN, + ROOT_URL, and its Caddy block — freeing the bare root for the + website. The DuckDNS wildcard already resolves the new subdomain, so no DNS + work is needed. +

+
-readonly GITEA_DOMAIN="kuhy.duckdns.org"
++readonly GITEA_DOMAIN="gitea.kuhy.duckdns.org"
+ +

b. Turn the monolith into per-service snippets

+

+ The generated root Caddyfile stops holding blocks and just imports a folder; + Gitea writes its own snippet; the Caddy container mounts that folder. +

+
     volumes:
+       - ./Caddyfile:/etc/caddy/Caddyfile:ro
++      - ./sites:/etc/caddy/sites:ro          # mount the snippet folder
+
+-	cat >"$CADDYFILE" <<EOF
+-${GITEA_DOMAIN} { reverse_proxy 127.0.0.1:3000 }
+-EOF
++	ensure_dir "$SITES_DIR"
++	cat >"$CADDYFILE" <<'EOF'          # root file: just an import
++import /etc/caddy/sites/*.caddy
++EOF
++	cat >"${SITES_DIR}/gitea.caddy" <<EOF   # Gitea owns ONE file
++${GITEA_DOMAIN} { reverse_proxy 127.0.0.1:3000 }
++EOF
+ +

c. The new deploy script — ordering is load-bearing

+

+ setup_personal_website.sh orchestrates the whole cutover. The + order is the safety property: existing blocks are migrated + into snippets before the root file is switched to the import line — + otherwise dufs and SyncYomi would blink out during the migration itself. +

+
preflight            # deps, docker up, gitea-caddy present
+migrate_caddy_snippets   # split TODAY's Caddyfile → sites/*.caddy (keep dufs, sync)
+relocate_gitea       # re-run setup_gitea.sh (now writes import + gitea.caddy)
+build_website        # pnpm build → ~/personal-website/dist
+write_website_stack  # loopback caddy compose + inner Caddyfile
+write_website_snippet# sites/website.caddy → kuhy.duckdns.org
+start_website        # container up on 127.0.0.1:8088
+reload_caddy         # single reload, now that every snippet exists
+ensure_firewall      # 80/443 already open — idempotent
+ +

d. The one word that keeps the site alive

+

+ The website's inner Caddyfile disables the admin API so a reload aimed at the + edge can never hijack it (Problem B). +

+
{
+	admin off          # host networking shares :2019 with gitea-caddy
+}
+:8088 {
+	root * /srv
+	try_files {path} /index.html   # SPA fallback
+	file_server
+}
+ +

+ The React site itself was later extracted to its own repo + (github.com/kuhyx/personal-website); this commit is the + edge + deploy half that lives in testsAndMisc. +

+
+ +
+

4 · Quiz

+

Five questions on the why. Score: 0 / 5.

+
+
+ + + +
Generated by the explain-diff skill · testsAndMisc @ 0bdaecd
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 30e6f49b..ca7a25da 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,7 +30,7 @@
testsAndMisc

Change explanations

-

4 explanations · interactive walkthroughs of notable changes, newest first.

+

5 explanations · interactive walkthroughs of notable changes, newest first.