Skip to content

Reverse proxy

The proxy is the only component that faces the internet. Everything else talks over the internal Docker network and publishes nothing.

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

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.

Terminal window
cd helsa/deploy
docker 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 caddyfile

There is a make target for this. Use it as a reflex before restart proxy.

handle takes at most one path argument. This is not a shorthand for two routes:

handle /healthz /readyz { … } # ✗ parse error, proxy will not start

Use a named matcher:

@health path /healthz /readyz # ✓
handle @health { … }

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:

Terminal window
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/healthz

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.

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.

The requirements are proxy-independent:

  1. Client certificate required, not optional (ssl_verify_client on).
  2. Trust anchor is your CA file alone (ssl_client_certificate), no public roots.
  3. Pass the certificate subject upstream as a header.
  4. /healthz may answer trivially; it must not expose internal state.
  5. 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.