Expose the UI remotely (TLS + reverse proxy)¶
The UI binds 127.0.0.1 by default — reachable only from the box itself. Reaching
it from a laptop or phone is a conscious opt-in: the API must never travel
plaintext off-box. Two safe shapes:
Private overlay network (Tailscale/WireGuard) — the tailnet already gives you transport encryption and device identity, so you can bind to it directly.
Public exposure behind a TLS reverse proxy (Caddy, nginx, Cloudflare Tunnel) — the proxy terminates HTTPS and forwards to dccd on loopback.
Never bind ui_host: 0.0.0.0 on a public IP without one of the above. The
ui_auth_token (Protect the web UI with a token) is defence-in-depth, not transport security —
it authorises API calls but does not encrypt anything.
Note
Always set ui_auth_token as well. Behind a proxy or on a tailnet it is the
second factor that stops anyone who reaches the port from driving the API.
Caddy (recommended)¶
Caddy gets you automatic Let’s Encrypt TLS in two lines. Keep
ui_host: 127.0.0.1 so only Caddy talks to dccd:
your.host.example {
reverse_proxy 127.0.0.1:8080
}
That is the whole Caddyfile. Caddy provisions the certificate, terminates TLS,
and forwards to dccd. It sets X-Forwarded-Proto: https and overwrites
X-Forwarded-For with the real client address by default — dccd relies on both
(the first to mark the session cookie Secure, the second as the rate-limit client
key when proxy trust is enabled). Server-Sent Events stream through Caddy with no
extra config.
Reload after editing: sudo systemctl reload caddy (or caddy reload).
nginx (alternative)¶
SSE needs buffering off and the forwarding headers set explicitly (nginx does not add them for you):
server {
listen 443 ssl;
server_name your.host.example;
# ssl_certificate / ssl_certificate_key via certbot
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# SSE: do not buffer the event stream
proxy_buffering off;
proxy_set_header Connection '';
# dccd relies on these; overwrite (not append) so a client
# cannot inject a forged X-Forwarded-For hop.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $host;
}
}
Provision the certificate with certbot (certbot --nginx).
Cloudflare Tunnel (no public port)¶
cloudflared dials out to Cloudflare, so you open no inbound port and TLS is
terminated at the edge:
cloudflared tunnel login
cloudflared tunnel create dccd
# route a hostname to the local service:
cloudflared tunnel route dns dccd your.host.example
cloudflared tunnel --url http://127.0.0.1:8080 run dccd
Keep ui_host: 127.0.0.1. This is a good fit for a box behind NAT with no public
IP.
Tailscale (private, no public TLS needed)¶
On a tailnet the transport is already encrypted and authenticated, so you can bind
the UI to the tailnet and reach it by the device’s 100.x address:
settings:
ui_host: 0.0.0.0 # reachable on the tailnet interface
ui_auth_token: "a-long-random-string"
Reach it at http://<device>.<tailnet>.ts.net:8080 or the 100.x IP. Because
there is no public TLS proxy here, the cookie is not marked Secure — that is
acceptable because the tailnet itself is the encrypted boundary. Still set
ui_auth_token as defence-in-depth. (This machine’s throwaway test box is reached
exactly this way.)
Verify the front¶
After wiring a proxy, confirm TLS and that SSE actually streams (the classic buffering footgun):
curl -fsS https://your.host.example/health # {"status":"ok"} over TLS
curl -v https://your.host.example/health 2>&1 | grep -i 'SSL\|HTTP/' # cert chain, no -k
# SSE must stream frames, not block until close:
curl -N https://your.host.example/api/events?token=YOUR_TOKEN
And confirm the plaintext port is not reachable off the box when bound to loopback:
curl --max-time 3 http://<box-ip>:8080/health # should refuse / time out
Hardening¶
For a deployment reachable beyond your own machine, three opt-in settings (all off by default) reduce the blast radius:
settings:
ui_rate_limit: 10 # max requests/sec per client on /api/* (0 = off)
ui_readonly: false # true = block POST/PUT/PATCH/DELETE on /api/*
ui_trusted_proxy: true # trust X-Forwarded-For for the rate-limit client key
ui_rate_limittoken-buckets/api/*per client; over budget returns429withRetry-After. Tune to taste (browsing the UI issues a handful of calls).ui_readonlyturns the instance into a safe, view-only share: every mutating route (job CRUD, backfill run/cancel, stream start/stop) returns403whileGETviews keep working.ui_trusted_proxydecides the rate-limit client key. Leave it off unless the app is reachable only through a reverse proxy that overwritesX-Forwarded-For— otherwise a direct client can forge the header and bypass the limit. With it off, the key is the socket peer (the proxy’s address if you’re behind one).
Checklist¶
ui_auth_tokenis set.The plaintext
:8080is not published to the public internet (loopback bind + proxy, a tunnel, or a private tailnet only).The proxy sets
X-Forwarded-Protoand overwritesX-Forwarded-For.SSE (
/api/events) streams through the proxy (buffering off).
Threat model¶
Know what this setup does and does not protect, so you pick a posture that matches your exposure.
Trust boundaries
Localhost only (the default
127.0.0.1bind) — no remote attacker; no token needed. This is the safe default.Private overlay (Tailscale/WireGuard) — the tailnet provides transport encryption and device identity. Binding to it is acceptable; the token is defence-in-depth.
Public internet — only behind a TLS reverse proxy. The API must never be reachable in plaintext.
What the token / session protects
It is API authorisation for a single shared secret — not per-user identity, not multi-tenant. The browser session is an opaque,
HttpOnlycookie; the raw token is never embedded in a served page.SameSite=Laxmeans a cross-sitePOST/DELETEdoes not carry the cookie, so the mutating routes are CSRF-protected.
What it does NOT protect
It is not a substitute for TLS — use a proxy or a private overlay for encryption.
A shared token has no per-user revocation beyond rotating it (change
ui_auth_tokenand restart).Rate-limit and session state live in-process and reset on restart (fine for a single-node daemon).
Residual risks & mitigations
?token=in an SSE URL can land in proxy/access logs — browsers use the cookie path by default, so prefer it; reserve?token=for non-browser clients.Enable
ui_trusted_proxyonly behind a proxy that overwritesX-Forwarded-For— otherwise the rate-limit key is forgeable.Use
ui_readonly: truefor a view-only share; boundui_rate_limit; keepdata_pathunder the service’sStateDirectory(see Deploy dccd on a server (run it unattended)).
Recommended postures
Exposure |
Bind |
TLS |
Token |
Notes |
|---|---|---|---|---|
LAN / localhost |
|
n/a |
optional |
Default; nothing reachable off-box. |
Tailnet |
|
overlay |
yes |
Tailscale encrypts + authenticates; token is defence-in-depth. |
Public |
|
proxy |
yes |
Behind Caddy/nginx/Tunnel; set |
See also Protect the web UI with a token (token handling), Deploy dccd on a server (run it unattended) (run it unattended), and Sync data to a remote (S3, GCS, …) (off-box backups).