Notes from getting the learning journal served over HTTPS, and making it survive reboots. Two parts: Caddy as a TLS-terminating reverse proxy, and systemd to keep things alive.
Why a reverse proxy at all
The blog is a tiny Go binary that serves static files. It could, in principle, handle TLS itself — but that means teaching it about certificates, renewals, port 80/443 privileges, and HTTP->HTTPS redirects. That's a lot of moving parts to bolt onto a file server.
Instead, put Caddy in front. The division of labour:
- Caddy faces the internet, terminates TLS, and forwards plain HTTP to the Go server on localhost.
- The Go server binds to
127.0.0.1:8080and does one thing: serve files. It never touches the public internet directly.
This is the standard shape: a hardened, purpose-built proxy at the edge, a simple app behind it.
The setup
Caddy installs as a systemd service from the official repo. The entire config is four lines:
syd.sparlay.me {
reverse_proxy 127.0.0.1:8080
}
That's it. Caddy sees the domain, automatically requests a Let's Encrypt certificate, renews it forever without a cron job, and proxies everything to the Go server. The automatic HTTPS is the headline feature - no certbot, no manual renewal, no config beyond naming the domain.
Ports 80 and 443 need to be open (80 for the certificate challenge, 443 for HTTPS), and DNS for the domain must already point at the box.
port 443 was taken
Caddy refused to start:
listen udp :443: bind: address already in use
The cause was a genuine surprise. An old service was configured to use the port and it had never actually been removed; the process was alive the whole time, quietly holding the exact port the blog now needed.
Caddy's HTTP/3 support uses UDP 443 for QUIC, which is what collided.
The diagnosis was a chain:
ss -ulnpshowed a UDP 443 socket with no owning process — a clue it was orphaned or owned oddly.lsof -i :443proved nothing was listening on TCP 443.- Tracking down the old process and removing it for real freed the port.
Lesson
"Address already in use" means find the current owner before assuming the conflict is your new service's fault. The squatter was a process I thought was long gone.
A second, smaller lesson: when in doubt, disabling HTTP/3 (protocols h1 h2
in a global Caddy block) sidesteps the whole UDP 443 question. HTTP/2 over
TCP is fine for a small blog.
Making it an ever-living service
A server you start by hand dies when you close the terminal — and then the proxy in front of it returns 502 Bad Gateway, because it has nothing to talk to. The fix is a systemd unit so the file server runs in the background and restarts on boot or crash:
[Unit]
Description=Blog static file server
After=network.target
[Service]
Type=simple
User=qsar
ExecStart=/home/qsar/go/blog/serve/serve -dir /home/qsar/go/blog/public -addr 127.0.0.1:8080
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now blog
enable makes it start on boot; --now starts it immediately. With both
Caddy and this service enabled, the whole stack comes back on its own after
a reboot — no terminal, no manual steps.
A gotcha worth recording: if a manually-started copy of the server is still
holding the port, the systemd unit can't bind and gets stuck in
activating, retrying forever. Kill the stray process first, then restart
the service.
The shape of it now
internet ──HTTPS──> Caddy (:443, TLS) ──HTTP──> serve (127.0.0.1:8080) ──> files
Two enabled services, one four-line config, an auto-renewing certificate, and a file server that can't be reached except through the proxy. For a 512MB box, that's a clean place to be.