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, 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:
127.0.0.1:3000127.0.0.1:5000127.0.0.1:8282Caddy 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.
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…
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.
:2019admin 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.
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"
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
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 → ~/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
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.
Five questions on the why. Score: 0 / 5.