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.
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:
127.0.0.1:3000127.0.0.1:5000Caddy 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.