# Additional domains A store app declares exactly **one** route, on the deployment's primary domain: ```yaml # docker-compose.yml — from the store, never modified services: outline: labels: caddy_0: outline-${APP_DOMAIN} caddy_0.import: gateway_tls caddy_0.reverse_proxy: "{{upstreams 80}}" ``` Reaching that same app at a **second** name — an `sslip.io` / `nip.io` host, so a box with no DNS configured is still usable — is a property of the *deployment*, not of the app. So it is configured once, in **Settings › Domains**, and Maison generates the extra routes for every app. ## The domain list ```jsonc // /AppData/maison/settings.json "domains": [ { "name": "nip", "domain": "${APP_PUBLIC_IP_DASH}.nip.io", "directives": { "import": "gateway_tls" } }, { "name": "sslip", "domain": "${APP_PUBLIC_IP_DASH}.sslip.io" } ] ``` | Key | Meaning | |---|---| | `name` | Label in the settings UI. Also names the generated route in the override's comment. | | `domain` | The host suffix. **Stays templated** — it is copied into the Caddy label as-is and resolved by Compose interpolation, exactly like the primary `${APP_DOMAIN}`. That is what keeps the route correct when the box changes IP: nothing is baked in. | | `directives` | The Caddy sub-directives this domain owns. | **TLS belongs to the domain, not to the app.** In a Yundera deployment the gateway and `nip.io` hosts are served with the deployment's own CA (`import: gateway_tls`, which also restores `X-Original-Host` behind the CF worker), while `sslip.io` deliberately carries nothing and falls through to Let's Encrypt. An app never says anything about this, and never has to: `directives` is where it lives. The list is empty by default — apps are then reachable only at the primary domain, exactly as their compose routes them. ## The settings panel **Settings › Domains** edits exactly this list, and is built to say what the list *is*: - **The primary domain is shown first, read-only** — as the token an app's own route is written with (`${APP_DOMAIN}`) and as the host it currently resolves to, with the label a store app already carries. An extra name means nothing without the name it is extra to, and a list on its own reads like a domain switcher. - **Nothing is preset.** sslip.io and nip.io used to be one-click chips here, which put a Yundera deployment's routing policy in the dashboard's UI. Every entry is written by the operator; the panel lists the interpolation variables this deployment actually has (read off `.env.app`) so a templated host is discoverable without naming a wildcard DNS service. - **Directives are editable per entry**, as key/value rows, because that is where the difference between two domains lives — the same app, on one host with `import: gateway_tls` and on another with nothing at all. Each entry previews the label group it generates, resolved against this deployment's variables. - **Applying is explicit.** Saving rewrites every app's override and recreates every running container (below), so an editing session is batched behind one button rather than fired per keystroke. The panel is served by `GET`/`PUT /api/settings/domains`; the GET carries the primary domain and the routing variables alongside the list. Only the routing family of `.env.app` travels — that file also holds the seeded admin password. ## What gets generated For each service, Maison finds every `caddy_N` label whose host references the primary domain (`${APP_DOMAIN}`, `${DOMAIN}` or `${domain}`) and clones the whole group, once per configured domain, into the app's **`docker-compose.override.yml`**: ```yaml # docker-compose.override.yml services: outline: labels: # Maison: nip — generated from caddy_0 (Settings › Domains) caddy_1: outline-${APP_PUBLIC_IP_DASH}.nip.io caddy_1.import: gateway_tls caddy_1.reverse_proxy: "{{upstreams 80}}" # Maison: sslip — generated from caddy_0 (Settings › Domains) caddy_2: outline-${APP_PUBLIC_IP_DASH}.sslip.io caddy_2.reverse_proxy: "{{upstreams 80}}" x-compose-app: # generated by Maison — the routes it owns in this file, and will rewrite generated-routes: outline: [caddy_1, caddy_1.import, caddy_1.reverse_proxy, caddy_2, caddy_2.reverse_proxy] ``` Four rules make that work: - **The whole group is cloned, not just `reverse_proxy`.** A route can be an entire `handle_path` tree (Seafile's is) or carry `header_up_*` / `transport.*` directives, and it has to keep working on the second domain. - **Except the TLS directives** (`import`, `tls*`), which are dropped and replaced by the domain's own — see above. - **Every route group is cloned, on every service** — not just the one behind the app's web UI. Outline's Dex sidecar has its own `outline-auth-${APP_DOMAIN}` route, and OIDC breaks if it isn't reachable at the same names as the app. - **A host that is already routed is skipped**, whether the store shipped it or the operator wrote it. So an app store that still carries its own `sslip.io` labels is never published twice, and Maison can be rolled out before the store is trimmed. "Already routed" is decided on the **resolved** host, not on the written text: a compose can reach one address through two spellings of the same variable — the PCS's own stack writes `maison-${PUBLIC_IP_DASH}.nip.io` while the configured domain is `${APP_PUBLIC_IP_DASH}.nip.io` — and compared as text those look like two hosts, which published the app twice on one name with two different TLS settings. `stackup.SyncRoutes` resolves with the variables `docker compose` will interpolate the labels with (`envinject.Render`); a reference that cannot be resolved is left as written, so two genuinely unknown hosts never collapse into each other. The same resolution decides which stale override routes belong to a configured domain and get cleaned up. Indices are allocated after the highest one either file already uses, so a generated route never lands on top of a hand-written one. ## The override is shared, so generation is exact The base compose is off limits: it is byte-compared against the store's on every update check, so a label written there would read as a permanent "update available". The override is the only legal target — but it is also **the operator's file**. So Maison does not guess which labels are its own. It records the keys it wrote in the `generated-routes` manifest, and the next run deletes **exactly those** before writing the new set. Everything else in the file — a hand-written service tweak, a comment, a custom `caddy_9` route, key order — is left untouched, because the file is patched through its YAML node tree rather than re-emitted. Remove the last domain and the generated routes and the manifest disappear, leaving the override exactly as Maison found it (and deleting it outright if it had nothing else in it). > One deliberate exception: a route in the override whose host sits on a *configured* > domain is treated as Maison's even if the manifest doesn't list it — that is how > generation recovers if the manifest is edited away through the YAML view. If you > want a hand-written route on one of these domains, don't configure that domain. ## When it runs Route generation is a step in the **up sequence**, so it happens on every path into a stack: ``` sync routes → ensure folders → pre_up → docker compose up -d → post_up ``` Install, start, store update, a config or `.env` save, and adding a domain all converge on the same file. A Caddy label is read off the *container*, so a domain change only reaches a running app through a recreate anyway — which is exactly what saving the domain list does: **Maison republishes every running app** (stopped ones are left alone; they pick the routes up whenever they are next started). ## `docker compose up -d` still works The whole feature stays inside the two files Compose loads by itself — no third file, no `-f` flags. A `cd /DATA/AppData/outline && docker compose up -d` brings up exactly what Maison brings up, generated routes included. For that to hold, the variables the labels are templated with have to be resolvable from the folder. `${APP_DOMAIN}` and `${APP_PUBLIC_IP_DASH}` come from the deployment's environment, which a hand-run compose doesn't have — so Maison seeds them into the app's `.env` (only when the key is **missing**: a value the operator changed by hand always wins).