Skip to content

Managed Caddy and routing

Demo transcript

One proxy. Local HTTPS domains. Route each service by path.

devhost routes local apps through one shared managed Caddy instance.

Download the managed Caddy binary if you do not already have caddy on your PATH:

Terminal window
devhost caddy download

devhost uses that downloaded binary when present. Otherwise it falls back to the global caddy executable from your PATH. It does not auto-download Caddy during devhost caddy start or stack startup.

On Linux, set up low-port binding for the managed Caddy binary once before the first HTTPS start:

Terminal window
devhost caddy privileged-ports

That command downloads the managed Caddy binary first when needed, then runs sudo setcap 'cap_net_bind_service=+ep' against that managed binary so later devhost caddy start runs can stay unprivileged.

[!IMPORTANT] To get HTTPS working, Caddy uses a self-signed certificate that is not trusted by default.

devhost caddy trust prompts for your password and installs Caddy’s CA into the system trust store.

If you want to trust that same managed Caddy CA from another macOS machine without exposing the Caddy admin API, run this from the client machine:

Terminal window
devhost caddy trust-remote devbox

That command SSHes to devbox, runs devhost caddy print-root-cert remotely, prints the fetched root certificate SHA-256 fingerprint, and installs it into the local macOS System keychain. It requires ssh locally, devhost on the remote host’s PATH, and a root certificate that has already been generated on the remote host by devhost caddy start.

Start the shared managed Caddy instance before running one or more stacks:

Terminal window
devhost caddy start

Pass the manifest explicitly to use its caddy.global.adminAddress, bindHost, http, httpPort, and httpsPort settings when generating the shared proxy configuration. This applies to devhost caddy start, stop, and trust:

Terminal window
devhost caddy start --manifest ./devhost.toml

Active stack registrations take precedence when generating the shared admin address, bind host, and listener ports. While registrations remain, plain HTTP is the OR of active stacks’ caddy.global.http votes; a supplied manifest cannot enable HTTP over false/default active votes. Without active registrations, generated configuration uses the supplied manifest’s settings, including HTTP.

Lifecycle commands contact the admin endpoint selected by the supplied manifest, so use matching custom settings when managing a custom shared proxy. They do not discover an empty runtime’s settings from its existing Caddyfile.

You can also set DEVHOST_MANIFEST=./devhost.toml as the environment-backed equivalent of --manifest for devhost start, devhost stop, and devhost caddy start|stop|trust. If both are set for the same command, the CLI flag wins.

Stop it when you are done with all stacks:

Terminal window
devhost caddy stop

For a custom proxy, pass the matching manifest to stop it:

Terminal window
devhost caddy stop --manifest ./devhost.toml

Managed Caddy lifecycle is shared and manual. devhost stack startup requires the managed Caddy admin API to already be available.

Each stack captures the resolved shared management settings after startup stale cleanup. Sparse/default route settings inherit that binding; explicit non-default conflicts still fail. Stack shutdown removes its host snippets and keeps the captured management endpoint and listeners. Live sibling registrations remain authoritative for HTTP: removing the last true voter disables HTTP while false/default siblings remain.

Final-registration cleanup retains the retiring stack’s captured settings, including HTTP, and leaves Caddy running for manual stop. Once no registrations remain, a newly started stack must supply matching custom settings in its manifest; it does not automatically discover the empty custom runtime. See Shared managed Caddy settings for the shared-vote rules.

devhost manages HTTPS routing through Caddy, not DNS. Your chosen hostnames must already resolve to this machine or the browser will never reach the local proxy.

For custom domains, that means loopback resolution, such as exact A / AAAA records to 127.0.0.1 / ::1, wildcard DNS records on your domain, or local host entries for exact names. /etc/hosts can be used, but it only handles exact hostnames.

Good out-of-the-box choices are localhost and subdomains under *.localhost, such as foo.localhost and api.foo.localhost, because they work without additional DNS configuration.

Use an array in host to route several hostnames to one service process:

[services.web]
command = ["bun", "run", "dev"]
port = 3000
host = ["app.localhost", "alias.localhost"]

Every hostname uses the same backend port and path, and must resolve to the machine running devhost. The array must contain at least one valid, unique hostname. Each hostname is claimed independently, so a conflict on any domain prevents startup and releases the claims already acquired.

The first hostname supplies DEVHOST_HOST, {{ services.web.host }}, and the devtools service link. Startup logs show every routed URL. All domains share the service’s annotation queues for each annotation action.

All domains follow the service’s port on restart, including after an automatic port retry. Route updates publish together; a failed refresh restores every domain’s previous route and leaves the service available for recovery in devtools.

Multiple projects can run against the same managed Caddy instance at the same time.

The routing contract is strict:

  • hostname ownership is exclusive across projects
  • one project cannot claim a hostname that is already owned by another live devhost process
  • one manifest may mount multiple services under the same hostname on distinct paths
  • fixed numeric bind ports are claimed globally across devhost processes before service spawn, and claim failures report a quoted normalized listening command line plus manifest path when devhost can discover the active socket listener
  • port = "auto" is best-effort; devhost retries on clear bind collisions, but it does not provide a cross-process global auto-port allocator

The generated Caddy config uses these defaults:

  • state dir: DEVHOST_STATE_DIR, else XDG_STATE_HOME/devhost, else ~/.local/state/devhost
  • admin API: 127.0.0.1:20197 by default via caddy.global.adminAddress
  • HTTPS listener port: 443 by default via caddy.global.httpsPort = 443
  • listener binding on macOS: wildcard listeners, because macOS denies rootless loopback-specific binds on the default privileged HTTPS port
  • listener binding on non-macOS: loopback only by default via caddy.global.bindHost = "127.0.0.1", rendered as Caddy default_bind 127.0.0.1 [::1]
  • plain HTTP: disabled by default; any active stack with caddy.global.http = true enables the same routed hosts and shared fallback page on HTTP for all active stacks
  • HTTP listener port: 80 by default via caddy.global.httpPort = 80
  • unmatched hostnames: a generated 404 page listing the active devhost hostnames as HTTPS links

On macOS, the managed Caddy instance starts rootlessly by avoiding loopback-specific listener binding. That keeps startup unprivileged, but it also means the managed Caddy instance is not loopback-only on that platform. If you need strict loopback-only HTTPS on privileged ports, use a privileged launcher such as launchd socket activation; wildcard binding is not an equivalent substitute.

On non-macOS platforms, opening HTTPS on the configured caddy.global.httpsPort still requires privileged-port setup outside devhost when that port is privileged. Enabling caddy.global.http = true adds the same requirement for caddy.global.httpPort when that port is privileged. On Linux, devhost caddy privileged-ports configures the managed Caddy binary for this with setcap. devhost does not configure authbind or firewall redirection for you.

caddy.global.bindHost, caddy.global.httpPort, and caddy.global.httpsPort only change the public HTTP/HTTPS listeners. caddy.global.adminAddress configures the separate managed Caddy admin API endpoint.