---
title: "Hostnames and HTTPS"
url: https://devlanes.run/docs/guides/https/
description: "Every service with hosts is reachable at https://<host> through the daemon's proxy."
updated: 2026-11-01T20:02:18.000Z
---

# 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

1. The browser resolves `api.acme2.localhost`. Chrome, Firefox and the resolvers of macOS 26, systemd-resolved and Windows 11 answer `*.localhost` with loopback by themselves; nothing is configured. Older macOS versions and Windows Server do not ([programs that cannot resolve *.localhost](#programs-that-cannot-resolve-localhost)).
2. 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.
3. 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.
4. The request goes to `127.0.0.1:<service port>` over HTTP/1.1 with `Host` preserved, `X-Forwarded-Proto: https`, `X-Forwarded-Host`, `X-Forwarded-For`, `X-Forwarded-Port` and `Forwarded`. WebSocket upgrades are bridged with no idle timeout.
5. 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](/docs/errors/) lists every page.

## 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](/docs/guides/remote-multiple-boxes/)).

## Trust the CA once

```sh
devlanes setup
```

lists 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](/docs/guides/wsl/#trusting-the-ca-in-windows)). `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

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 |
| <code>curl.<wbr>exe</code> 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

`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

```yaml
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 Host
infra:
  hosts:
    minio: "s3.${{ lane.domain }}"   # a container's published port
```

`lane.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()`](#a-site-of-its-own), 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](/docs/remote/) sends.

### 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](#programs-that-cannot-resolve-localhost) 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:

```yaml
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

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](/docs/guides/windows/#hostnames)).

The second is the resolver strategy:

```yaml
# ~/.devlanes/config.yaml
hostnames:
  resolver: resolver
  tld: test
```

```sh
devlanes setup
```

switches 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](/docs/guides/windows/#hostnames)). 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

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:

```sh
sudo setcap cap_net_bind_service=+ep "$(command -v devlanes)"   # redo after updates
# or
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
```

A 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](/docs/guides/windows/#ports)).

### 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`:

```yaml
proxy:
  https_port: 8443
  http_port: 8080
```

## 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](/docs/remote/).

## Checking

```sh
devlanes url ls                                   # routed hostnames with lane, unit, port and state
curl -sS https://api.acme2.localhost/healthz     # works without -k once the CA is trusted
curl -sS --cacert ~/.devlanes/ca/ca.pem https://api.acme2.localhost/healthz
devlanes doctor                                   # proxy ports, CA, trust per store, hosts file
```

On 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.
