Skip to content

External setup

What stays outside the repo.

A daedalus-managed machine rebuilds from its config repo, except for these: the accounts, dashboards, two keys and one router that live outside git. Each is configured once, by hand. This page is what every install still needs.

Cloudflare

The zone and the tunnel

One zone, one API token, one tunnel. All public HTTP enters through the tunnel. DNS for the zone splits two ways: records the repo syncs, and records you manage by hand. Confusing them is the classic mistake.

the zone

hand-made

A domain on Cloudflare. Every published app is exactly one label under it, so one wildcard certificate covers the fleet and no per-app DNS work exists.

the tunnel

re-issuable

Created once via the API. Cloudflare returns the tunnel secret only in the creation response, so the dashboard wizard can't be used. Ingress is locally managed from the rendered config; editing it in the dashboard does nothing. The credential rides sops-encrypted in the config repo, which is the whole backup.

synced CNAMEs

One proxied CNAME per published app, upserted and swept on every rebuild. Any record pointing at the tunnel that isn't declared gets deleted. The repo owns this class of record; the dashboard doesn't.

hand-made records

hand-made

Anything you point elsewhere yourself: a dynamic A record for a raw game port, a CNAME for a static site. Keep them DNS-only (grey cloud) and on a hostname no app declares, or the sweep will overwrite them and the proxy will swallow the port.

the API token

re-issuable

One token for everything Cloudflare on the box. Zone › Zone › Read and Zone › DNS › Edit cover the proxy's DNS-01, the route sync, the dynamic address and the control plane's domain picker; Account › Cloudflare One Connector: cloudflared › Read covers its tunnel panels. Include all zones and a domain added later shows up without touching the token. Stored once, so rotating it is one edit. Losing it is an outage, not data loss.

Let's Encrypt

One wildcard

The ACME account is created implicitly on first run. One certificate covers the apex and the wildcard, issued over DNS-01 with the token above.

DNS-01, pinned upstream

Challenges resolve against 1.1.1.1 directly. The LAN's own resolver can't see a fresh TXT record, and waiting on it would time every renewal out.

the cert store

re-issuable

Not in any backup tree. It reissues itself from nothing, but Let's Encrypt rate-limits duplicates, so copy it aside before risky disk work.

The router

Forward nothing you didn't choose

The only hardware configuration in the system. Public HTTP arrives through the tunnel, so 80 and 443 are never forwarded. The router can't be declared, so each forward you do make is written down in the repo beside the stack that needs it, where a reader would look.

what gets forwarded

hand-made

Only protocols that can't ride the tunnel: a WireGuard endpoint, a game server. No TLS means no SNI for a proxy to route on, and no tunnel client inside a game launcher.

DHCP: off

The box's DNS server is also the LAN's DHCP server, so the router's must be off. The box itself boots on a static IP, since there'd be nobody to lease from that early.

everything else: closed

SSH, HTTP, DNS and the database are LAN-only. Port scans from the internet die at the router; public traffic exists only inside the tunnel.

GitHub

Keys and builds

The repos live here; the builds do not. A push wakes the box, which builds the image itself and lands it in its own registry. GitHub holds the source and the keys.

the config repo

re-issuable

Private. It holds the machine: every stack, every sops-encrypted secret, and the ledger of what's outside it. A deploy SSH key, its public half registered by hand in account settings, authenticates the weekly autoupgrade push.

one repo per app

Each app is a repo. A push to main reaches the box as a webhook, which builds the image on its own hardware (Railpack, unless the repo has a Dockerfile and no railpack.json) and pushes it to the box's registry, then starts the app's deploy.

the box's own GitHub App

re-issuable

Created and installed from the control plane. Its private key is sealed into the config repo and stays host-side; the container only ever sees a one-hour installation token, scoped to reading contents and Actions runs and writing checks and deployments.

Pages

hand-made

This site. Built by Actions, custom domain set once in the repo's Pages settings, paired with a hand-made grey-cloud CNAME in the zone.

↳ .github/workflows/website.yml

Mail

The alert channel

Every alert, disk failure and dead-man ping emails through one SMTP relay. One app password, one encrypted copy, shared by every consumer. None duplicates it.

one app password

re-issuable

Two-factor on, app password issued once. Consumed by the system sendmail, the dashboards, the dead-man switch and anything else that needs to reach you.

the honest footnote

The box resolves DNS through itself, so an alert about the resolver being down cannot leave the box. Known and accepted.

Custody

The two keys

Everything above is re-issuable: lose it and you have an outage. The two keys here are not. Lose either and no rebuild, snapshot or provider dashboard brings it back.

the age recovery key

keep safe

Every secret in the config repo decrypts for exactly two identities: the box's SSH host key, and your personal age key, whose password-manager copy is the recovery of last resort. Lose both and the repo's secrets are ciphertext forever; every provider relationship above gets rebuilt by hand.

the identity provider's encryption key

keep safe

One environment variable encrypts the identity provider's signing keys at rest. Set it once and treat it as fixed. Rotating it means re-encrypting everything it protects; losing it means every session and every app registration starts over.

passkeys

keep safe

The identity provider's first boot is interactive: an admin account and a passkey, registered once. The passkeys on your devices are the credential; the last resort is a one-time token minted from a shell on the box.

Still manual

The steps that stay steps

Three moves no rebuild makes for you. Each is one command, if you know it exists.

after rotating a secret

A rebuild alone is the false success: rendered copies keep serving the old value. Restart the render unit and its consumer, by hand, every time.

after importing a fresh pool

Child datasets are not auto-created. Each one is a one-time create. The repo lists every child; the pool doesn't.

after losing the box

Images live only in the box's own registry, so every app needs one build before its first deploy on new hardware.

The pattern, if you're building your own: when something can't be declared, declare that it exists — beside the stack that needs it, in the place a reader would look. A port forward lives in a registry no rebuild consumes; your config repo should carry its own copy of this page, filled in.

declared where possible, written down where not