Hostnames and HTTPS
Every service with hosts is reachable at https://<host> through the daemon’s proxy. The lane sits in the registrable label (api.acme2.localhost, never api.lane2.acme.localhost), so each lane is its own site for cookies and storage, like api.acme.com and app.acme.com share acme.com in production.
The flow
Section titled “The flow”- The browser resolves
api.acme2.localhost. Chrome, Firefox and the resolvers of macOS 26, systemd-resolved and Windows 11 answer*.localhostwith loopback by themselves; nothing is configured. Older macOS versions and Windows Server do not (programs that cannot resolve *.localhost). - It connects to port 443 (or 8443 when 443 could not be bound) and sends the hostname in the TLS handshake. The proxy looks the name up in its route table. An unknown name is refused at the handshake, so only registered hostnames ever get a certificate.
- The proxy signs a leaf certificate for that name with the devlanes CA, valid 30 days, cached for 24 hours, and completes the handshake with HTTP/2 or HTTP/1.1.
- The request goes to
127.0.0.1:<service port>over HTTP/1.1 withHostpreserved,X-Forwarded-Proto: https,X-Forwarded-Host,X-Forwarded-For,X-Forwarded-PortandForwarded. WebSocket upgrades are bridged with no idle timeout. - Plain
http://on port 80 (or 8080) answers known hostnames with a 308 to https.
A request to a starting service waits until it is ready. A hostname whose service is stopped or has failed gets a 503 page with the commands to diagnose it and, for failures, the end of the service’s output. 404 means no route has that name; 508 means a service proxied a request back to devlanes without rewriting Host. Proxy errors lists every page.
Open services by hostname
Section titled “Open services by hostname”Open a service at its hostname, such as https://api.acme2.localhost, and not at http://localhost:<port>. The proxy gives the hostname HTTPS, HTTP/2, WebSockets and the lane’s own site for cookies. Plain http:// on port 80 only redirects known hostnames to HTTPS. A raw port skips all of that. The browser treats localhost:<port> as another origin, and cookies set there reach every port on localhost, every lane’s included. Bookmarks, OAuth callbacks and CORS settings that name the hostname also keep working when the lane moves to another box (several boxes with the same project).
Trust the CA once
Section titled “Trust the CA once”devlanes setuplists what this machine needs (the CA, plus the hosts helper or the resolver file below when they apply), marks each step ok, todo or skip, and asks once before doing the todo ones. devlanes setup --check only reports, and exits 1 when something is missing. The CA is created on first use at ~/.devlanes/ca/ca.pem (key ca-key.pem, mode 0600) and the daemon writes ~/.devlanes/ca/bundle.pem, the system roots plus the CA. setup runs in your terminal, never in the daemon, because it needs your password. When a start on a terminal runs without the TUI and one of the lane’s URLs needs a missing step (an untrusted CA for an https:// address, the hosts helper or resolver for a hostname), devlanes start prints a line naming the first such URL’s host, such as acme.localhost needs devlanes setup (CA not trusted), at most once a day for the same steps. The TUI’s setup prompt follows the same rules and shares that day.
What it does per OS, by the stores in tls.trust (default [system]):
| OS | system | chrome | firefox |
|---|---|---|---|
| macOS | login keychain, trusted as root for your user (GUI password dialog, no sudo); Safari, Chrome, Edge and /usr/bin/curl read it; Firefox 120+ too | same store as system | certutil into every Firefox profile; needs brew install nss |
| Linux | anchor directory of your distro (/usr/local/share/ca-certificates, /etc/pki/ca-trust/source/anchors, …) then update-ca-certificates or update-ca-trust, through sudo | ~/.pki/nssdb with certutil; Chrome, Chromium and Playwright’s Chromium read it, Playwright’s Firefox does not | every profile under ~/.mozilla/firefox (snap and flatpak paths too); needs libnss3-tools or nss-tools |
| Windows | your user root store Cert:\CurrentUser\Root, no administrator rights; Windows asks you to confirm each change in a security warning; Chrome, Edge and curl.exe read it; Firefox 120+ too | same store as system | same store as system |
On Linux the system store does not reach browsers, so set tls.trust: [system, chrome, firefox] in ~/.devlanes/config.yaml there. Inside WSL, where the browser usually runs on Windows, add windows for the Windows user root store (WSL). devlanes setup --remove removes the CA from the same stores, along with the hosts helper and the resolver file; --keep-ca removes only those two.
devlanes doctor reports the CA path and, per store, whether it is trusted. The CA is constrained to localhost, test, internal, home.arpa and the TLD configured when it was created; a hostname outside those is reported by doctor as outside the CA's names.
Programs started by devlanes
Section titled “Programs started by devlanes”Services do not need the system store. Each unit gets DEVLANES_CA_CERT and DEVLANES_CA_BUNDLE, and the platform sets the variable its runtime reads: NODE_EXTRA_CA_CERTS (Node, Bun), DENO_CERT, SSL_CERT_FILE and REQUESTS_CA_BUNDLE (Python, Rust). A service calling https://auth.acme2.localhost from its own code therefore works without any TLS workaround. Remove NODE_TLS_REJECT_UNAUTHORIZED=0 and hand-rolled NODE_EXTRA_CA_CERTS from dev scripts; a script that overwrites the variable loses the CA.
Other tools from your shell:
| Tool | Reads | Do |
|---|---|---|
| Homebrew curl, OpenSSL CLI | SSL_CERT_FILE, CURL_CA_BUNDLE | export SSL_CERT_FILE=~/.devlanes/ca/bundle.pem, or devlanes env shell |
| Go binaries | the OS store on macOS; SSL_CERT_FILE on Linux | nothing on macOS |
curl. on Windows | the Windows store, and a revocation list the devlanes CA does not publish | add --ssl-revoke-best-effort |
| Java | its own cacerts | keytool -importcert -file ~/.devlanes/ca/ca.pem |
| Docker containers | nothing from the host | mount ca.pem and run update-ca-certificates, or talk to services by host.docker.internal:<port> over http |
| iOS simulator, Android emulator | own stores | not handled yet; tls.trust accepts the names and ignores them with a warning |
mkcert
Section titled “mkcert”tls.ca: mkcert makes the daemon load mkcert’s rootCA.pem from mkcert -CAROOT instead of creating its own CA. Browsers that already trust mkcert keep working, and setup reports mkcert’s root as ok once it is trusted in the stores of tls.trust. mkcert’s root has no name constraints and setup --remove leaves it alone (mkcert -uninstall removes it).
Hostnames in config
Section titled “Hostnames in config”services: api: hosts: - api.${{ lane.domain }} - host: "*.${{ lane.domain }}" fallback: true # used only when no other route matches - host: "inspect.${{ lane.domain }}" port: inspector # route to a named port - host: "legacy.${{ lane.domain }}" host_header: upstream # send 127.0.0.1:<port> as Hostinfra: hosts: minio: "s3.${{ lane.domain }}" # a container's published portlane.domain is acme.localhost in lane 0 and acme<N>.localhost elsewhere, such as acme2.localhost. Keep every hostname under it or under a name from domain(), so lanes never share a name. A domain you write yourself in lanes.vars must also end in .localhost, or in your hostnames.tld with the resolver strategy. Anything else does not resolve to your machine. Matching is exact first, then wildcards (longest first), then fallbacks. A wildcard covers any depth below its parent but not the parent itself; list the apex too when you need it. A name claimed by two lanes goes to the one with running units and both get a host_conflict warning.
hosts also change the default readiness check to tcp on the service’s port.
Tenant-style subdomains (*.acme-pages2.localhost) are fine as long as the lane index is in the label: "*.${{ domain('acme-pages') }}" in the full example. Browsers reject a wildcard directly under a TLD (*.localhost), and devlanes refuses one that a remote box sends.
A site of its own
Section titled “A site of its own”A browser treats app.acme2.localhost and api.acme2.localhost as one site: acme2.localhost. A cookie the app sets with Domain=acme2.localhost reaches every name under it. That is what you want for the app and its API. It is wrong for names that serve content you don’t control, such as user-generated pages, which must not read or set the app’s cookies.
domain(name) gives such a name a site of its own in every lane, by the rule lane.domain follows. domain('pages') is pages.localhost in lane 0 and pages2.localhost in lane 2. With the resolver strategy it is pages2.test, unless the project sets lanes.vars.domain or lanes.matrix. Those projects keep .localhost. Use it in lanes.vars to name the site once:
lanes: vars: pages_domain: ${{ domain('acme-pages') }}services: web: hosts: app.${{ lane.domain }} # app.acme2.localhost, the app's site pages: hosts: "*.${{ lane.pages_domain }}" # alice.acme-pages2.localhost, a site apart env: PAGES_DOMAIN: ${{ lane.pages_domain }}Write pages.${{ lane.domain }} instead when the name may share the app’s cookies. Requests from such pages to the app count as same-site, so SameSite cookies don’t keep them out.
Pick a name only this project uses, such as domain('acme-pages'). Two projects that both call domain('pages') get the same names. lane.domain itself is domain(project.name) unless lanes.vars.domain sets it, so project names can meet too: lane 1 of web2 and lane 0 of web21 are both web21.localhost, and then both lanes get a host_conflict warning.
The argument must be hostname labels, and anything else is a config error.
Programs that cannot resolve *.localhost
Section titled “Programs that cannot resolve *.localhost”workerd (Wrangler), musl binaries, pure-Go programs built without cgo, programs that use the system resolver on macOS before 26 (Safari included) and anything inside a container send *.localhost to DNS. Programs on the host have two answers; containers have neither and should use host.docker.internal:<port> (see the table above). The first is the hosts file: devlanes setup installs a small root-owned helper (/usr/local/libexec/devlanes-hosts) with a sudoers rule for it alone, and the daemon then keeps a # devlanes begin / # devlanes end block in /etc/hosts with the exact hostnames of projects that want it. Wildcards cannot be written there; programs on the host reach wildcard hostnames only with the resolver strategy below, and only under its TLD. The user setting hostnames.hosts_file is auto by default, which writes names only when a startup probe shows the system resolver fails *.localhost; lanes.hosts_file: always in a project forces it for that project (Wrangler repos want this), never in the user config wins over every project. setup installs the helper when the probe fails, when the user config asks for the hosts file, or when the daemon reports that a project needs it. crates/hosts-helper/README.md describes what the helper accepts and refuses. devlanes never writes the hosts file on Windows and setup has no helper to install there. Windows 11 resolves every *.localhost name itself. Windows Server does not; add the names to its hosts file by hand (Windows).
The second is the resolver strategy:
hostnames: resolver: resolver tld: testdevlanes setupswitches lane domains to acme.test and acme2.test, answered by a DNS server the daemon runs on 127.0.0.1:15353. setup writes /etc/resolver/test on macOS, or a systemd-resolved delegate on Linux (systemd 258 or newer; older versions could only add the server globally, so setup refuses them). On Windows it adds an NRPT rule after an administrator prompt, and the server listens on 127.53.0.1:53 instead, because an NRPT rule cannot name a port (Windows). The OS sends only that TLD there, and every other lookup keeps your existing DNS (VPN, corporate DNS, Pi-hole). The server answers every name under the TLD, so wildcard hostnames resolve for every program that uses the system resolver; on macOS dig and nslookup bypass it. The TLD must be test, internal, home.arpa or a name under one of them (or a name under localhost). The CA is constrained to the TLD it was created with; switching later needs the recreate command doctor prints. .dev is a real TLD with HSTS preloaded and .local is mDNS; neither is accepted.
Ports 443 and 80
Section titled “Ports 443 and 80”macOS lets a user process bind the low ports on the wildcard address; devlanes does so and accepts only loopback peers unless proxy.lan: true. On Linux the daemon falls back to 8443 and 8080 unless it may bind low ports:
sudo setcap cap_net_bind_service=+ep "$(command -v devlanes)" # redo after updates# orsudo sysctl -w net.ipv4.ip_unprivileged_port_start=80A fallback is a doctor warning, and public URLs then carry the port: https://api.acme2.localhost:8443. proxy.https_port and proxy.http_port set other ports on purpose.
Windows needs no privilege for the low ports and binds them on loopback. Port 80 there is often held by http.sys, and ports inside the ranges Hyper-V or WSL reserved cannot be bound; both fall back the same way (Windows).
Next to another reverse proxy
Section titled “Next to another reverse proxy”A local Caddy, nginx or Traefik on 443 and 80 competes with the daemon for those ports. On macOS and Linux whichever starts second cannot bind them: a daemon that starts second falls back to 8443 and 8080, and another proxy that starts second fails to start. On Windows both start, because the other proxy listens on the wildcard address and the daemon on loopback, and requests to localhost on those ports reach only the daemon; doctor warns when another program listens on a proxy port. To run both, move the daemon to other ports in ~/.devlanes/config.yaml and run devlanes daemon restart:
proxy: https_port: 8443 http_port: 8080Access from other devices
Section titled “Access from other devices”proxy.lan: true accepts connections from the network. Devices still have to resolve the hostnames to your machine (their own hosts file, or a DNS server pointing *.test at your IP with the resolver strategy) and trust the CA. Mirroring a box’s lanes to a laptop does not need this; see remote boxes.
Checking
Section titled “Checking”devlanes url ls # routed hostnames with lane, unit, port and statecurl -sS https://api.acme2.localhost/healthz # works without -k once the CA is trustedcurl -sS --cacert ~/.devlanes/ca/ca.pem https://api.acme2.localhost/healthzdevlanes doctor # proxy ports, CA, trust per store, hosts fileOn Windows, add --ssl-revoke-best-effort to both curl.exe commands.
ERR_CERT_AUTHORITY_INVALID in a browser means that browser’s store does not have the CA: Firefox and Chromium on Linux need their own entries in tls.trust. A certificate error for a name that url ls lists under another lane means two lanes claim it. curl: (35) or handshake failure on a name url ls does not show means the service has no hosts entry for it.