# Deploying MyDay behind Zoraxy > **Status:** the reference deployment for **D-043** / **D-044** / **D-045**. The app is never the > TLS endpoint; **[Zoraxy](https://github.com/tobychui/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: ```yaml 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://: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:** ```bash ufw allow from 10.1.0.10 to any port 8080 proto tcp # the default incoming policy (deny) drops every other source ``` **nftables:** ```nft 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:** ```yaml # config.yaml — on the container setup: true setup_code: "" # optional: a second factor (see below) ``` ```bash 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): ```bash # 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: ```bash 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`](../deploy/myday.service) — replace every ``: ```bash # 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 -g /opt/myday install -m 0640 -o -g 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=`, and `After=network-online.target`; see the file's header comment for the placeholders. --- ## 7. Verify, and the misconfig warnings ```bash # 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`](../PROJECT_PLAN.md) | D-043 · D-045 (the setup gate) · D-044 (the optional code) | | [`docs/specs/spec-003-core-door.md`](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`](specs/spec-005-setup-wizard.md) | §3/§4 the bootstrap + the optional code | | [`config.example.yaml`](../config.example.yaml) | the commented config template | | [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |