NextMyDay/docs/deploy-zoraxy.md
Claus Lohmar f58d33aee1 docs: add the Zoraxy deployment guide and systemd unit
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.
2026-10-15 06:15:41 +00:00

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.md D-043 / D-025 (revised); the gate's behaviour is docs/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 for myday.sechpoint.app, terminates TLS, and forwards to the app over plain HTTP.
  • App — listens on 0.0.0.0:8080 over HTTP (server.listen). Because the app is not the TLS endpoint it must honour the proxy's X-Forwarded-Proto to know a request arrived over https (that is what turns on the Secure + __Host- session cookie and HSTS).
  • Firewall — :8080 is reachable only from 10.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). Bind 0.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 be https:// (WebAuthn derives its Relying Party ID from it).
  • trusted_proxies — the peers whose X-Forwarded-For / X-Forwarded-Proto are honoured (A6/D-043).

It must be the address Zoraxy connects from, not the public hostname. trusted_proxies matches the TCP peer of the app's connection. With Zoraxy on 10.1.0.10, that peer is 10.1.0.10. (If Zoraxy ran on the same host you would use 127.0.0.1 instead — 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: https is what makes the session cookie Secure (__Host-myday_session) and adds HSTS (AC-13).
  • The absence of X-Forwarded-For is 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 writes config.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-Proto is what keeps the cookie Secure over 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