Reverse proxy
The proxy is the only component that faces the internet. Everything else talks over the internal Docker network and publishes nothing.
Before you open a port
Section titled “Before you open a port”The two interfaces
Section titled “The two interfaces”| Port | Serves | Gate | Forwarded |
|---|---|---|---|
443 |
/v1/*, /healthz, /readyz |
Mutual TLS, mandatory | Yes — the only one |
8443 |
Dashboard and its API calls | Source IP must be in your LAN or VPN range | No |
Configuration
Section titled “Configuration”deploy/caddy/Caddyfile:
{ # No automatic Let's Encrypt: this deployment uses a private CA. auto_https off admin off}
# ---------------------------------------------------------------------------# API — mutual TLS gate. Publicly routable.# ---------------------------------------------------------------------------https://:443 { tls /etc/caddy/pki/server.crt /etc/caddy/pki/server.key { client_auth { # require_and_verify: without a valid certificate signed by OUR CA # the TLS handshake fails. The request never reaches HTTP. mode require_and_verify trust_pool file { pem_file /etc/caddy/pki/ca.crt } } }
handle /v1/* { reverse_proxy api:8080 { header_up X-Client-Cert-Subject {http.request.tls.client.subject} header_up X-Client-Cert-Serial {http.request.tls.client.serial} } }
# Health endpoints stay trivial: no internal state in the response. # They are still behind mutual TLS — the gate covers the whole site. @health path /healthz /readyz handle @health { reverse_proxy api:8080 }
handle { respond "not found" 404 }}
# ---------------------------------------------------------------------------# Web dashboard — LAN and VPN only. NOT forwarded on the router.# ---------------------------------------------------------------------------https://:8443 { tls /etc/caddy/pki/server.crt /etc/caddy/pki/server.key
@allowed remote_ip {$LAN_SUBNET} {$WG_SUBNET}
handle @allowed { handle /v1/* { reverse_proxy api:8080 } handle { reverse_proxy web:80 } }
handle { respond "forbidden" 403 }}LAN_SUBNET and WG_SUBNET come from .env, for example
LAN_SUBNET=192.168.1.0/24 and WG_SUBNET=10.100.0.0/24.
Why the client certificate details are passed upstream
Section titled “Why the client certificate details are passed upstream”X-Client-Cert-Subject lets the API log which device is talking without having to
terminate TLS itself. It is useful for debugging a multi-device setup and for
correlating an upload with a device.
Validate before you deploy
Section titled “Validate before you deploy”cd helsa/deploydocker run --rm -e LAN_SUBNET -e WG_SUBNET \ -v "$PWD/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ -v /opt/helsa/pki:/etc/caddy/pki:ro \ caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile --adapter caddyfileThere is a make target for this. Use it as a reflex before restart proxy.
The gotcha that will get you
Section titled “The gotcha that will get you”handle takes at most one path argument. This is not a shorthand for two
routes:
handle /healthz /readyz { … } # ✗ parse error, proxy will not startUse a named matcher:
@health path /healthz /readyz # ✓handle @health { … }Acceptance test
Section titled “Acceptance test”Run these against the deployed server before you consider it done. The first one is the important one.
| Test | Expected |
|---|---|
curl https://helsa.example.net/healthz with no client certificate |
TLS handshake fails. Not a 401, not a 403 — the connection does not complete. |
| Same, with a valid client certificate | 200 |
/readyz with a valid certificate |
200 — database, Redis, and queue all reachable |
/v1/summary with a certificate but no token |
401 — the application layer is a separate gate |
| An unknown path with a certificate | 404 |
Dashboard on :8443 from the LAN |
Served |
Dashboard on :8443 from outside the allowed ranges |
403 |
:8443 from the internet |
Connection times out — the router does not forward it |
With client certificates, curl looks like:
curl --cacert pki/out/ca/ca.crt \ --cert pki/out/clients/iphone/iphone.crt \ --key pki/out/clients/iphone/iphone.key \ https://helsa.example.net/healthzDynamic DNS is now load-bearing
Section titled “Dynamic DNS is now load-bearing”If the phone reaches the server through a dynamic DNS name, that name is on the critical path for syncing. When the record goes stale — an expired API token in the updater is the classic cause — syncing stops silently. The phone buffers, so nothing is lost, but nothing arrives either.
- Put the expiry of any dynamic-DNS API token in your calendar.
- Rely on the freshness alert to catch it, since it catches every cause of “no new data” at once.
Request size
Section titled “Request size”The API enforces a limit on ingest chunk size and answers 413 above it. Consider
enforcing a body limit at the proxy as well: it makes an oversized request cheap
to reject, before it is buffered and parsed by the application.
nginx instead of Caddy
Section titled “nginx instead of Caddy”The requirements are proxy-independent:
- Client certificate required, not optional (
ssl_verify_client on). - Trust anchor is your CA file alone (
ssl_client_certificate), no public roots. - Pass the certificate subject upstream as a header.
/healthzmay answer trivially; it must not expose internal state.- The dashboard route must not be served on the public interface.
Caddy is the documented choice mainly because the mutual-TLS block is short and
caddy validate catches mistakes before they take the service down.