Document the D-043 deployment: the browser -> Zoraxy (10.1.0.10, TLS) -> app (0.0.0.0:8080, http) topology, the config.yaml, the Zoraxy ProxyHost headers (X-Forwarded-For + X-Forwarded-Proto), the firewall rule, the tunnel-only bootstrap, systemd, and the misconfig warnings. - config.example.yaml: trusted_proxies: ["10.1.0.10"] with the silent-failure and firewall caveats. - deploy/myday.service: the systemd unit (Type=simple, Restart=on-failure, User/WorkingDirectory/ExecStart, After=network-online.target). - docs/deploy-zoraxy.md: the full walkthrough. - README.md: the Deployment section points at the guide. Docs/deploy only — no code change.
8.1 KiB
Deploying MyDay behind Zoraxy
Status: the reference deployment for D-043. The app is never the TLS endpoint; Zoraxy terminates TLS and relays plain HTTP to the app. This document is operator-facing — it changes no code. The decision of record is
PROJECT_PLAN.mdD-043 / D-025 (revised); the gate's behaviour isdocs/specs/spec-003-core-door.md§7.
1. Topology
browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0:8080
(TLS public) terminates TLS (plain HTTP, trusted proxy)
sets X-Forwarded-For
+ X-Forwarded-Proto
- Zoraxy —
10.1.0.10, holds the certificate formyday.sechpoint.app, terminates TLS, and forwards to the app over plain HTTP. - App — listens on
0.0.0.0:8080over HTTP (server.listen). Because the app is not the TLS endpoint it must honour the proxy'sX-Forwarded-Prototo know a request arrived overhttps(that is what turns on theSecure+__Host-session cookie and HSTS). - Firewall —
:8080is reachable only from10.1.0.10(see §4). The port is the app's whole attack surface: anything that can reach it bypasses both Zoraxy and its TLS.
2. The app config (config.yaml)
Copy config.example.yaml → config.yaml (gitignored — never commit it, D-010)
and set the three server keys:
server:
listen: "0.0.0.0:8080"
base_url: "https://myday.sechpoint.app" # the PUBLIC origin (invite / recovery links)
trusted_proxies:
- "10.1.0.10" # Zoraxy
listen— any bind is allowed (there is no setup-mode bind refusal, D-025 revised). Bind0.0.0.0:8080; the firewall narrows who can reach it.base_url— the public origin. It is the host the browser actually visits, so it must behttps://(WebAuthn derives its Relying Party ID from it).trusted_proxies— the peers whoseX-Forwarded-For/X-Forwarded-Protoare honoured (A6/D-043).
It must be the address Zoraxy connects from, not the public hostname.
trusted_proxiesmatches the TCP peer of the app's connection. With Zoraxy on10.1.0.10, that peer is10.1.0.10. (If Zoraxy ran on the same host you would use127.0.0.1instead — that is the only case where loopback is right.)
3. The Zoraxy ProxyHost
Create a Proxy Host (reverse proxy rule) in Zoraxy:
| Field | Value |
|---|---|
| Domain / hostname | myday.sechpoint.app |
| TLS | on (Zoraxy's certificate for that host) |
| Upstream / target | http://<app-host>:8080 (plain HTTP) |
X-Forwarded-For |
set / forwarded |
X-Forwarded-Proto |
set / forwarded (https) |
Zoraxy sets X-Forwarded-For on a normal HTTP proxy rule; confirm it and
X-Forwarded-Proto in the rule's header settings, then verify end-to-end (§7).
The app deliberately requires both:
X-Forwarded-Proto: httpsis what makes the session cookieSecure(__Host-myday_session) and adds HSTS (AC-13).- The absence of
X-Forwarded-Foris the proof the setup gate uses that a request is the direct SSH tunnel, not a proxy relay (§6, D-025 revised).
4. Firewall — lock :8080 to Zoraxy
Allow :8080/tcp only from 10.1.0.10.
ufw:
ufw allow from 10.1.0.10 to any port 8080 proto tcp
# the default incoming policy (deny) drops every other source
nftables:
table inet myday_guard {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 8080 ip saddr != 10.1.0.10 drop
}
}
If you run a host firewall, this rule is the load-bearing one: without it the app's plain-HTTP port is directly reachable and its TLS hardening (cookie
Secure, HSTS, the tunnel gate) is bypassed.
5. Tunnel-only bootstrap (before the first admin exists)
The first user becomes the top admin, so the initial /setup wizard is served
only over a direct SSH tunnel — never through the proxy. This is enforced
per request (D-025 revised / SPEC-003 §7): in setup mode the app serves a
request only if its TCP peer is loopback and it carries no
X-Forwarded-For — i.e. the tunnel. Everything else is 404.
# from your workstation — tunnel to the app's loopback port on the server
ssh -L 8080:127.0.0.1:8080 <user>@<app-host>
# then, in a browser on your workstation:
# http://127.0.0.1:8080/setup
- Open the app over
localhost/127.0.0.1— a loopback origin is the only one the browser treats as a secure context for WebAuthn dialogs. - The wizard creates the top admin (Passkey + TOTP; the TOTP secret is sealed
with the generated
totp_encryption_key) and writesconfig.yaml(setup: false, the base URL, mail/locale, and the secrets). - A proxy cannot reach a fresh instance: even a same-host proxy presents a
loopback peer but always adds
X-Forwarded-For, which gives it away → 404.
The restart — adopting the persisted secrets
The secrets are generated at startup while any value is still CHANGE_ME…
(so the wizard's ceremony is never signed with a shipped, public key). The wizard
then persists those same values into config.yaml. So once setup is complete:
sudo systemctl restart myday
The restart adopts the written config.yaml — the generated keys are now on
disk, so the admin's TOTP secret (sealed with that key) keeps working. Boot the
productive instance from the written config.yaml, never from
config.example.yaml (its placeholders fail closed in productive mode by
design). After the restart, /setup is 404 and the app serves normally.
6. systemd
Build, install the unit, and start it. The unit is
deploy/myday.service — replace every <PLACEHOLDER>:
# 1. build the static, CGO-free binary
go build -trimpath -o /usr/local/bin/myday ./cmd/myday
# 2. the app dir holds config.yaml and data/ (SQLite)
install -d -o <myday-user> -g <myday-group> /opt/myday
install -m 0640 -o <myday-user> -g <myday-group> config.yaml /opt/myday/config.yaml
# 3. the unit
install -m 0644 deploy/myday.service /etc/systemd/system/myday.service
systemctl daemon-reload
systemctl enable --now myday
journalctl -u myday -f
The unit sets WorkingDirectory= to the app dir (where config.yaml and data/
live), User=, ExecStart=<binary>, and After=network-online.target; see the
file's header comment for the placeholders.
7. Verify, and the misconfig warnings
# from anywhere — the public origin
curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security'
# expect: Strict-Transport-Security: max-age=… (HSTS — proves X-Forwarded-Proto was honoured)
| Symptom | Cause | Fix |
|---|---|---|
Session cookie has no Secure, no __Host- prefix, and no HSTS |
trusted_proxies is empty or wrong, so X-Forwarded-Proto is ignored (fails silently) |
Set server.trusted_proxies: ["10.1.0.10"] and restart |
| Client IP in the audit log is the proxy, not the user | same — X-Forwarded-For is not trusted |
as above |
/ and /home return 404 while bootstrapping |
by design — in setup mode only /setup (and static assets) is served |
complete the wizard; the app serves normally once productive |
/setup returns 404 even via the proxy |
by design — the gate is tunnel-only | bootstrap over the SSH tunnel (§5), then expose it |
The load-bearing deployment requirement: Zoraxy must set
X-Forwarded-For. The setup gate's "no XFF ⇒ it's the tunnel" test depends on it, and a missing header would re-open first-visitor-wins.X-Forwarded-Protois what keeps the cookieSecureover the proxy.
See also
PROJECT_PLAN.md |
D-043 · D-025 (revised) |
docs/specs/spec-003-core-door.md |
§7 the loopback/setup gate · §9 AC-7 / AC-10 / AC-13 |
config.example.yaml |
the commented config template |
deploy/myday.service |
the systemd unit |