- PROJECT_PLAN: register D-044, re-revise D-025 to "tunnel OR setup code",
update A13, O-8, §5.1, §12 and the change log.
- SPEC-003 §4/§7/§9/§12: the gate now serves the wizard to the tunnel OR an
unlocked request; AC-7/AC-10 reworded ("refused the wizard" — the boilerplate
may still answer `/` and `/setup`), AC-14 added.
- SPEC-005 §3/§4/§6/§9: the boilerplate, the unlock action, the code rules and
the ACs; AC-7 added for single-use/rate-limit/redaction.
- deploy-zoraxy §1/§3/§5/§7 + README: the LXC/Proxmox topology, how to read the
code (journalctl · pct exec 11020), the code gate replacing the tunnel-only
bootstrap (the tunnel stays as gate a); firewall and Zoraxy sections kept.
11 KiB
Deploying MyDay behind Zoraxy
Status: the reference deployment for D-043 / D-044. 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 (re-revised 2026-10-15b); the gate's behaviour isdocs/specs/spec-003-core-door.md§7 anddocs/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 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). - Container — MyDay runs in an LXC container (id
11020) under Proxmox. This is why the first-run gate is a setup code and not just an SSH tunnel: SSH/pct enterlands on the container gateway, not loopback, so a tunnel to127.0.0.1:8080cannot be formed (§5). - 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).X-Forwarded-Foris what keeps the setup gate's tunnel test (gate a, §5) honest: a request with noX-Forwarded-Forfrom a loopback peer is the direct tunnel, not a proxy relay.
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. First-run bootstrap — the setup code (no tunnel needed)
The first user becomes the top admin, so the initial /setup wizard is gated per request
— never first-visitor-wins (D-025 · D-044). There are two ways in:
| Gate | Who | Needs a code? |
|---|---|---|
(a) the direct SSH tunnel — a loopback TCP peer with no X-Forwarded-For |
a workstation that can tunnel to the app's loopback port | no |
| (b) the printed SETUP CODE | anyone who can read the server log, over the public domain | yes |
The setup code is the general gate for this topology. MyDay generates a 128-bit code at first start and prints it in an unmistakable startup banner:
======================================================================
MyDay is NOT set up yet — no admin exists.
======================================================================
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-4d5e-6f70-8192-a3b4-c5d6-e7f8
Read the code again:
journalctl -u myday (in the container)
pct exec 11020 -- journalctl -u myday (from the Proxmox host)
Read the code (in the LXC container, or from Proxmox):
# 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'
Then open https://myday.sechpoint.app/setup and enter the code. Until the code is entered:
GET /andGET /setupshow a friendly first-run page (with the code form) — never a bare 404, so the domain does not look "down";- every other route answers 404;
- the code is single-use and the entry form is rate-limited (wrong code → a generic 403).
No tunnel required. The old guidance ("bootstrap over an SSH tunnel first") no longer applies: in LXC a tunnel to
127.0.0.1:8080cannot be formed (it lands on the container gateway), which is exactly why D-044 replaced it with the code. The tunnel gate (a) still works and needs no code, if you have a route to the app's loopback port.
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
setup code is gone (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:inconfig.yamlto override the generated one (then protect the file). Leave it blank to have MyDay generate and print a fresh code each boot.
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 / and GET /setup show a first-run page with a code box |
by design — MyDay is not set up yet (D-044) | read the setup code (§5) and enter it; complete the wizard |
| Any other route returns 404 while bootstrapping | by design — only / and /setup are served in setup mode (SPEC-005 §3) |
complete the wizard; the app serves normally once productive |
| The setup code is rejected (403) | it is single-use (already spent) or mistyped | restart MyDay to print a fresh code, or pin setup_code: in config.yaml (§5) |
/setup returns 404 after the wizard |
by design — setup is complete | re-enter only via setup: true + restart |
The load-bearing deployment requirement: Zoraxy must set
X-Forwarded-For. It is what distinguishes a proxy relay from the direct tunnel (gate a): a proxied request with noX-Forwarded-Forwould look like the tunnel and open the wizard without a code. (The setup code still closes first-visitor-wins for anything the proxy does mark.)X-Forwarded-Protois what keeps the cookieSecureover the proxy.
See also
PROJECT_PLAN.md |
D-043 · D-025 (re-revised) · D-044 |
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 setup code |
config.example.yaml |
the commented config template |
deploy/myday.service |
the systemd unit |