NextMyDay/docs/deploy-zoraxy.md
Claus Lohmar 9fdc9eded6 docs: update the plan, specs and deploy guide for the setup toggle (D-045)
- PROJECT_PLAN.md: rewrite §5.1 for the toggle gate, mark D-025 superseded,
  refresh D-026 / O-8 / A13 / §12 and add the D-045 change-log entry.
- SPEC-005: §1/§3/§5/§6 (AC-1/AC-2/AC-7 + AC-8) reflect the three states and
  the optional code; §9 marks the D-044 tunnel bits superseded; new §10.
- SPEC-003: §4/§7 pipeline + AC-7/AC-10/AC-14; new §13 (D-045); D-025 ref
  removed from the governing decisions.
- docs/deploy-zoraxy.md: §5 rewritten around the toggle + optional code;
  §1/§2/§3/§7 de-tunnelled; see-also updated.
- README: first-run + deployment sections rewritten (enable setup: true,
  open /setup, complete, the app flips it off).
- config.example.yaml: document the toggle and the optional code.
2026-10-15 06:47:36 +00:00

12 KiB

Deploying MyDay behind Zoraxy

Status: the reference deployment for D-043 / D-044 / D-045. 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-045 (the setup gate; D-025 retired); the gate's behaviour is docs/specs/spec-003-core-door.md §7 and docs/specs/spec-005-setup-wizard.md §3/§4.


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                  │  runs inside LXC
                                                            ▼  container 11020 (Proxmox)
  • 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).
  • Container — MyDay runs in an LXC container (id 11020) under Proxmox. That is why the first-run gate is the setup config toggle rather than an SSH tunnel: SSH/pct enter lands on the container gateway, not loopback, so a tunnel to 127.0.0.1:8080 cannot be formed (§5).
  • 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). 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).
  • X-Forwarded-For is what makes the audit log and rate limiting see the real client (A6) instead of the proxy.

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) is bypassed. It also means the setup: true toggle is the only thing standing between a random client and the wizard — see §5.


5. First-run bootstrap — the setup toggle

The first user becomes the top admin, so the initial /setup wizard is gated by the setup config toggle (D-045) — never exposed by accident. There are three states:

setup admin exists? Behaviour
false no Locked. GET / and GET /setup show "MyDay is not set up — set setup: true in config.yaml and restart." Every other route → 404. Not claimable.
true either /setup serves the wizard (the operator's deliberate switch). GET / points at it. Every other route → 404.
false yes Productive — the app serves normally.

To bootstrap:

# config.yaml — on the container
setup: true
setup_code: ""       # optional: a second factor (see below)
sudo systemctl restart myday
# read the mode / the code in the log:
journalctl -u myday | grep -i 'setup'
# then open the wizard:
#   https://myday.sechpoint.app/setup

The startup banner states the mode and prints the setup URL:

======================================================================
  MyDay is in SETUP MODE — no admin exists yet.
======================================================================
  Open the setup wizard at:
      https://myday.sechpoint.app/setup
======================================================================

The optional setup code (D-044 · D-045). Set setup_code and the wizard requires it — the code form comes first (read the code from the startup log). Leave it empty and the toggle alone admits, so the wizard is served directly.

Read the code again (when one is set):

# inside the container
journalctl -u myday | grep -i 'setup code'

# from the Proxmox host (container 11020)
pct exec 11020 -- journalctl -u myday | grep -i 'setup code'

Until setup is finished: GET / points at the wizard, GET /setup serves it (or the code form), and every other route answers 404. The code is single-use and the entry form is rate-limited (wrong code → a generic 403).

Residual risk — setup: true is not claimable-proof. While the toggle is on, whoever reaches /setup first can claim the admin. Set setup_code (or complete setup immediately) if the domain is reachable by anyone else. This is the owner's deliberate trade (D-045): the toggle is the operator's switch; the code is an optional lock.

No tunnel required. The old guidance ("bootstrap over an SSH tunnel first") no longer applies: in LXC a tunnel to 127.0.0.1:8080 cannot be formed (it lands on the container gateway), and the peer/tunnel gate (D-025) is retired by D-045.

The wizard creates the top admin (TOTP; the passkey is added at first sign-in on the real origin — D-041) and writes config.yaml (setup: false, the base URL, mail/locale, and the secrets).

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, and the wizard is off (setup: false; GET /setup → 404). Boot the productive instance from the written config.yaml, never from config.example.yaml (its placeholders fail closed in productive mode by design).

Pinning your own code. Set setup_code: in config.yaml to require a code for the wizard (then protect the file). Leave it blank to have the setup: true toggle admit on its own.


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
GET / points at the wizard and GET /setup serves it (or a code box) by design — setup: true, the wizard is enabled (D-045) complete the wizard; if a code is required, read it from the log (§5)
GET / and GET /setup show "MyDay is not set up — set setup: true…" by design — setup: false and no admin (the LOCKED state) set setup: true in config.yaml and restart (§5)
Any other route returns 404 while bootstrapping by design — only / and /setup are served while the wizard is enabled (SPEC-005 §3) complete the wizard; the app serves normally once productive
The setup code is rejected (403) it is single-use (already spent), mistyped, or setup_code was changed check the code in config.yaml / the log; restart to re-arm
/setup returns 404 after the wizard by design — setup is complete (setup: false) re-enter only via setup: true + restart

The load-bearing deployment requirement: Zoraxy must set X-Forwarded-Proto (or the session cookie silently loses Secure/__Host- and HSTS) and X-Forwarded-For (or the audit log records the proxy, not the client). Neither is a setup gate any more (D-045) — the gate is the setup toggle; set setup_code if you want a lock on top of it.


See also

PROJECT_PLAN.md D-043 · D-045 (the setup gate) · D-044 (the optional code)
docs/specs/spec-003-core-door.md §7 the setup gate · §9 AC-7 / AC-10 / AC-13 / AC-14
docs/specs/spec-005-setup-wizard.md §3/§4 the bootstrap + the optional code
config.example.yaml the commented config template
deploy/myday.service the systemd unit