Skip to content

feat(infra): serve domain mode through Caddy with HTTPS - #111

Merged
arthware-dev merged 15 commits into
mainfrom
feat/domain-mode
Sep 22, 2026
Merged

arthware-dev merged 15 commits into
mainfrom
feat/domain-mode

Conversation

@arthware-dev

Copy link
Copy Markdown
Contributor

Summary

  • Domain mode works. Every stacklet is served at <id>.<domain> and core at the bare domain, through the infra stacklet's Caddy. stack up, stack down and stack destroy rebuild the routes from each running stacklet's caddy.snippet and reload Caddy. wiki.<domain> redirects to the family wiki, and your own sites go in ~/famstack-data/infra/Caddyfile.local.
  • HTTPS for everything with Let's Encrypt certificates (one wildcard plus the bare domain), obtained through your DNS provider's API, so nothing needs to be reachable from the internet. Hetzner and Cloudflare are supported.
  • Infra is marked beta, and its first stack up warns that it becomes DNS for the whole network.
[core]
domain       = "home.example.family"
dns_provider = "hetzner"   # or "cloudflare"; empty serves plain HTTP
stack up infra             # asks for the provider's API token
stack infra dns-token      # replaces it later

Setup, DNS records and token scopes: stacklets/infra/README.md. Routing and reload are tested end to end on a throwaway instance. HTTPS is tested against a real Caddy build, but no certificate has been issued with a real provider token yet.

Domain mode documented a Caddyfile assembled from each stacklet's
`caddy.snippet`, but nothing wrote it. The snippets were never read and
the proxy container mounted a file that did not exist.

In domain mode, `stack up`, `stack down` and `stack destroy` now write
`{data_dir}/infra/Caddyfile` from the snippets of the stacklets that are
up, and have a running Caddy reload it once the containers have changed.

- A crash-looping stacklet keeps its routes.
- A host no running stacklet claims gets a 404 from a `*.<domain>` site.
- Without TLS every site is served as plain HTTP on port 80.
- A reload Caddy rejects leaves it on its previous routes and prints a
  warning; the command itself still succeeds.
- Port mode writes nothing and reloads nothing.

Snippets now use `{$STACK_DOMAIN}`, the variable the Caddy container is
given. `{$FAMSTACK_DOMAIN}` was never set there.
Domain mode could only serve plain HTTP. With `[core] dns_provider` set
to `hetzner` or `cloudflare`, Caddy gets two certificates from Let's
Encrypt: a wildcard for `*.<domain>`, which every stacklet's subdomain
uses, and one for the bare domain, which a wildcard does not cover. It
proves ownership with a DNS-01 challenge through the provider's API, so
nothing on the LAN has to be reachable from the internet.

- `stack infra dns-token` stores the provider's API token as a secret,
  and replaces it. The first `stack up infra` asks for it in a
  terminal. The token is never printed, only its length.
- `stack up infra` refuses an unsupported provider or a missing token,
  and names the command that fixes it.
- An unsupported provider leaves the current Caddyfile in place for
  every other stacklet, with a warning.
- The URLs the stack renders (`{url}`, `{<id>_url}`, `{home_url}`) are
  https once a provider is set.

Hetzner needs a Hetzner Cloud API token with Read & Write access,
Cloudflare a token with Zone.Zone:Read and Zone.DNS:Edit on the zone.
Caddy needs the provider's plugin compiled in, which the infra image
gains in a separate change.
The fixture's command was a string, which compose splits itself, and the
splitting dropped the backslashes of `\r\n`. The response arrived as
one line with no header end, which the health probe tolerated and a
real HTTP client such as Caddy's reverse proxy rejects.

The command is now a list, passed to the container as written, and the
response closes the connection after the body.
A certificate through a DNS-01 challenge needs the DNS provider's plugin
compiled into Caddy, and the stock image has none, so the proxy could
not obtain certificates for `[core] dns_provider`.

The infra stacklet now builds its Caddy image with xcaddy on the Caddy 2
line, with both plugins, so switching providers is a config change and
not a rebuild. The Hetzner plugin is v2, which uses the Hetzner Cloud
API; v1 used the deprecated DNS Console API. `stack up infra` rebuilds
the image, and Watchtower leaves it alone because there is nothing to
pull.
The infra stacklet published DNS on 53 and the proxy on 80 and 443
through `PORT_BIND_IP`, which the framework sets to 127.0.0.1 in domain
mode, the only mode infra runs in. No other device could resolve a name
through AdGuard or reach a service through Caddy.

These three ports now bind all interfaces. `PORT_BIND_IP` still keeps
every service behind the proxy, and AdGuard's own admin pages, on the
loopback.
AdGuard's first-run wizard was published on host port 3000, where
development servers live. With one running, `stack up infra` failed on
a port conflict that named the port and not the stacklet.

The wizard is now on 42081, next to the admin panel on 42080, inside the
42xxx range every other stacklet uses. A new test checks that range for
every published port, and that no two stacklets publish the same one.

An instance that has finished the wizard is not affected.
AdGuard ran `:latest`, which Watchtower replaces with whatever is
published, a new minor line included. AdGuard publishes exact versions
only, with no line tag to follow, so the image is pinned to the current
release and moves with a bump in the compose file.
In domain mode the stack gives every stacklet the URL `<id>.<domain>`,
in hints, welcome messages and links other stacklets build, but several
snippets routed elsewhere:

- photos and chatai proxied to container names no compose file uses
  (`immich_server`, `chatai`), so both answered 502.
- messages served Synapse at `messages.<domain>` and Element at
  `element.<domain>`, while the stack sends browsers and Element's
  homeserver setting to `messages.<domain>`. It is now one host: Element
  for browsers, Synapse under `/_matrix` and `/_synapse`, and
  `/.well-known/matrix/client` and `/server` built from the request so
  they are right with and without TLS.
- memory served the wiki at `wiki.<domain>`, while its hint and core's
  `/go` links point at `memory.<domain>`. The wiki's own absolute links
  move with it.
- code had no snippet at all.

A new test checks every snippet's upstreams against the container names
in the compose files, and that every stacklet showing `{url}` has a site
at `<id>.<domain>`.
Clients on the LAN can reach the AI stacklet's endpoints through the
proxy: `stt.<domain>` for speech to text, `tts.<domain>` for text to
speech and `openai.<domain>` for the OpenAI-compatible LLM API.

whisper-server and oMLX stay bound to the Mac's loopback. The proxy
reaches them through Docker's host gateway, which the Caddy container
now maps as `host.docker.internal`, as every other container that
talks to the host already does.
The infra README pointed at `dns.<domain>` for AdGuard's admin pages,
but no snippet served it. The pages are there once the setup wizard has
run; before that the host answers 502.
Services that are not stacklets, such as a dashboard or a Docker UI,
had no place in domain mode. Only one proxy can own ports 80 and 443, so
a hand-written Caddyfile cannot run beside the one the stack assembles.

`{data_dir}/infra/Caddyfile.local` is now appended after the stacklet
snippets every time the Caddyfile is written. The stack reads it and
never writes it; `stack up infra` applies an edit. Its sites get the
wildcard certificate like any other subdomain. A host that a running
stacklet also serves makes Caddy reject the reload, with a warning.
The docs said domain mode needs "wildcard DNS on your router". It
needs two records to the Mac's LAN IP, `*.<domain>` and `<domain>`,
because a wildcard covers neither the bare name's DNS nor its
certificate. They belong in AdGuard's DNS rewrites, and at the DNS
provider for devices that bypass AdGuard.

- The infra README covers enabling domain mode, the DNS records, TLS
  (which token each provider needs, where it is kept, how to rotate
  it), `Caddyfile.local`, and the wizard on 42081.
- The admin guide explains `dns_provider` and the wiki's new host.
- The operator reference lists infra's ports and `stack infra
  dns-token`.
Domain mode now assembles its routes and can serve HTTPS, but HTTPS has
not yet run against a real DNS provider, and how TLS and the admin's own
sites are configured can still change. `stack list` now marks infra as
beta and `stack up infra` says so, with a reminder to back up first.
The family wiki is served at `memory.<domain>`, the address the stack
hands out for the memory stacklet, but `wiki.<domain>` is the name
people type. It now redirects to the same page on the memory host,
path and query included.

The redirect is temporary (302). Browsers keep a permanent one
indefinitely, which would get in the way if the family-facing name
changes again.
Pointing the router's DNS at the Mac makes every device in the house
depend on it, and setting domain mode up takes networking knowledge no
other stacklet asks for. Nothing said so.

- The first `stack up infra` now warns, once, before anything is set
  up: what depends on it, what knowledge it takes, and to note the
  router's current DNS setting first.
- The infra README and the admin guide say the same up front, and
  suggest port mode to anyone for whom this is new.
- The operator reference lists changing the router's DNS, and stopping
  or destroying infra, as danger zones for agents.
@arthware-dev
arthware-dev merged commit 08863e2 into main Sep 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant