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 — 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

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

  

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. dufs appended its 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 had appended its block later. So every time you re-ran the Gitea setup, that block 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 three 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 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)
relocate_gitea       # re-run setup_gitea.sh (now writes import + gitea.caddy)
build_website        # pnpm build → ~/src/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.