- 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.
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.mdD-043 / D-045 (the setup gate; D-025 retired); 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. That is why the first-run gate is thesetupconfig toggle rather than 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). 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 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 thesetup: truetoggle 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: trueis not claimable-proof. While the toggle is on, whoever reaches/setupfirst can claim the admin. Setsetup_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:8080cannot 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:inconfig.yamlto require a code for the wizard (then protect the file). Leave it blank to have thesetup: truetoggle 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 losesSecure/__Host-and HSTS) andX-Forwarded-For(or the audit log records the proxy, not the client). Neither is a setup gate any more (D-045) — the gate is thesetuptoggle; setsetup_codeif 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 |