docs / how it works

How lokal works

The two phases, the pieces of the background service, and why adding a service needs no sudo and returns only when the URL works.

On this page

lokal is one binary with two modes. As a CLI (lokal <command>), it edits your projects and talks to the background service. As the background service, it answers DNS for .lokal, issues certificates and proxies HTTPS to your dev servers. This page explains how those pieces fit together on macOS. Linux (a preview) uses the same proxy, certificates and state, with different system pieces: see On Linux.

Two phases#

  1. Setup, once per Mac. lokal setup is the only time anything privileged happens. Each of its one-time steps removes a privilege that would otherwise be needed at runtime.
  2. Daily use, fast and quiet. project and service commands edit a state file and tell the background service to reload. They need no sudo, no prompts and no restarts, and they return only when the URL works.

Here’s what each setup step buys:

ProblemOne-time step in setupResult at runtime
Browsers must trust the certificatesCreate a CA as you, and trust it in the System keychain (sudo)Certificates for your services are issued in memory. The keychain is never touched again
Ports 443 and 80 are privilegedInstall a LaunchAgent whose plist declares the sockets, so launchd binds themThe background service runs as you and inherits the sockets
*.lokal names must resolveInstall /etc/resolver/lokal (sudo), pointing at 127.0.0.1:53535lokal’s DNS server answers on a UDP socket that launchd holds

The pieces#

text
 browser / curl / node
   │
   ├─ 1. resolve api.shop.lokal ──▶ mDNSResponder (the macOS resolver)
   │                                  └─ *.lokal: /etc/resolver/lokal ──▶ lokal's DNS server (UDP 127.0.0.1:53535)
   │
   └─ 2. HTTPS to 127.0.0.1:443 ──▶ socket held by launchd ──▶ lokal: TLS with a leaf for the SNI ──▶ proxy ──▶ localhost:4000

 lokal CLI ──▶ state.toml (locked, atomic write) ──▶ IPC "reload" on lokal.sock ──▶ daemon updates routes and certificates
                                                     (and replies once every changed name is ready)

The background service is the same lokal binary, run by launchd as lokal daemon run from the LaunchAgent dev.lokal.daemon. It’s hidden from lokal --help, because you never run it yourself.

Certificates#

Setup creates a certificate authority on your Mac, as you, and macOS trusts it. The CA is name-constrained: it can only sign names under .lokal and .local (which lokal served before 0.6.0), never public domains or IP addresses. The security model covers what that protects.

The background service issues one leaf certificate per configured name, in memory, signed by that CA:

  • Each leaf has exactly one name, is valid for 90 days, and is issued again when fewer than 30 days remain.
  • Leaves are issued while the service is added (pre-warmed), so the first browser handshake never waits for signing. Issuing one takes around a millisecond.
  • Names under a wildcard service ('*') aren’t known in advance, so each one gets its own leaf during its first handshake, which adds that millisecond once. lokal keeps up to 1024 of these and drops the oldest when it needs room. The first time that happens, lokal logs shows a line saying so.
  • Nothing is written to disk or to the keychain. A restart issues fresh leaves for every name.
  • There are no wildcard certificates: even under a wildcard service, each leaf names exactly one name, the one the browser asked for. A TLS handshake for a name you didn’t configure, and that no wildcard service covers, fails, as does one with no SNI.

Names#

Every name lokal serves ends in .lokal, and every lookup ends at loopback and never leaves your Mac. /etc/resolver/lokal sends every *.lokal lookup to a small DNS server inside the background service, on 127.0.0.1:53535. It answers every name under .lokal with 127.0.0.1 and ::1, with a TTL of 0, so nothing is cached. Whether a name is actually served is decided by the proxy and the certificates: an unconfigured name resolves, but its TLS handshake fails.

.lokal names covers this from the user’s side, and why lokal stopped serving .local names in 0.6.0.

Ports and socket activation#

launchd, not lokal, binds TCP ports 443 and 80 on 127.0.0.1 and ::1, and UDP port 53535 on 127.0.0.1. The LaunchAgent declares the sockets, and the background service receives them from launchd when it starts (launch_activate_socket). This means:

  • the background service runs as you and never needs root;
  • every listener is on loopback, so nothing is reachable from your network;
  • connections that arrive while the service restarts wait in the socket’s queue instead of failing.

launchd keeps the service running (KeepAlive) and restarts it within a second if it crashes. This socket-activation approach, where launchd binds the privileged ports for an unprivileged agent, follows puma-dev.

The proxy#

The proxy terminates TLS (1.2 and 1.3, with HTTP/2 and HTTP/1.1 offered) and forwards each request to the service’s upstream:

  • Routing. It routes by the Host header, falling back to the SNI: to the service with that exact name, or else to the wildcard service with the longest base that covers it. The routing table is swapped atomically on each reload, so lookups never wait on a lock.
  • Upstreams. It talks HTTP/1.1 to your dev server over pooled keep-alive connections. For localhost upstreams it tries ::1 and 127.0.0.1 in parallel and remembers the winner for 30 seconds, because Node 17+ binds localhost to ::1 only and Python to 127.0.0.1 only.
  • Streaming. Request and response bodies stream in both directions, with no buffering and no compression. WebSockets are upgraded and copied both ways, so hot reload works. Proxied requests have no read timeout, so server-sent events and long polling work too.
  • Headers. It sets X-Forwarded-Proto: https, X-Forwarded-Host and X-Forwarded-Port: 443, and appends the client to X-Forwarded-For. With --host-header upstream it rewrites Host to the upstream’s host:port. It never sends Strict-Transport-Security.
  • Errors. If your dev server doesn’t accept a connection within 2 seconds, the proxy returns a 502 page that names the upstream. Plain HTTP on port 80 gets a 308 redirect to https:// for configured names, and a small 404 page that lists the project’s URLs for anything else. If a request’s Host belongs to a different name than the connection’s SNI, which can happen when HTTP/2 reuses a connection, the proxy answers 421 so the browser retries on a new connection.
  • Connection limits. A connection that isn’t proxying anything is closed 20 seconds after it opens if it sends no request, and after 60 idle seconds otherwise. Client and upstream connections are each capped below the background service’s open-file limit. When the cap is full, the connection idle longest makes room for a new one, so idle connections can’t lock everyone out. Proxied requests and WebSockets are never cut.

IPC and the state file#

The CLI and the background service share two things:

  • state.toml, your projects and services. Only the CLI writes it, always under a lock and with an atomic rename. The background service reads it, and also watches it, so hand edits apply without a CLI command.
  • lokal.sock, a Unix socket with mode 0600 in lokal’s data directory. The CLI sends one JSON request per connection: ping, status, reload or selftest. lokal status and lokal doctor use it, and every change ends with a reload.

Files and paths lists every file involved.

The fast path#

Every command that changes something (project create, project remove, service add, service update, service remove, up and down, and run when it adds or removes a service) takes the same path:

  1. It takes an exclusive lock on state.toml.lock, then reads, changes and atomically writes state.toml (temporary file, fsync, rename).
  2. It sends reload to the background service. The service compares the new state with the old one, issues the new certificates and swaps in the new routes. It replies only when every affected name is ready: its route is live and its certificate is issued. For a removal, it means the route is gone and the name is no longer served.
  3. It prints the result with the measured time, such as ready in 8ms.

That’s why service add needs no sudo: it only writes a file you own and talks to a process you own. The keychain, the ports and the resolver were set up once, during setup. And it’s why the URL works when the command returns: the command waits for the real event, not for a timer, so there’s no “wait a second and retry”. lokal’s live test relies on this. It requests each URL right after the command returns and fails if the first attempt doesn’t work.

If the background service isn’t running, or setup hasn’t run yet, the command still saves the change and prints a note saying what to run. If a name can’t be made ready, the command exits 1 and says which name failed and why.

Design decisions#

  • Wildcard .lokal answers. mDNSResponder caches any failed lookup for about 75 seconds. If lokal answered “no such name” for unknown names, a URL you opened before service add would stay broken for over a minute. So every .lokal name resolves, and “not configured” is enforced at the TLS layer instead.
  • A DNS server for .lokal. A resolver file plus a small DNS server works the same way on macOS and Linux, and needs no per-name registration. That’s the reason for the second privileged step in setup.
  • ThrottleInterval 1 in the LaunchAgent. launchd’s default 10-second throttle would delay restarting the background service after a crash. With 1, a killed service is back in well under a second.
  • A trust check that can fail. security verify-cert -r <root> makes the root an anchor, so it passes even when the keychain doesn’t trust it. lokal passes its root as a chain certificate (-c) instead, so the check passes only when macOS really trusts the CA.

On Linux#

On Linux, lokal is a preview. The proxy, the certificates, the DNS server, state.toml, the IPC socket and the fast path are the same code as on macOS. Setup puts different system pieces under them:

ProblemOne-time step in setupResult at runtime
Browsers and tools must trust the certificatesCreate the CA as you, add it to the system certificate store (sudo), and add it to Chrome’s and Firefox’s certificate databases with certutil (as you)Certificates are issued in memory, as on macOS
Ports 443 and 80 are privilegedInstall systemd user units whose socket units hold the ports, and let user services bind ports 80 and up (sudo, /etc/sysctl.d/50-lokal.conf)The background service runs as you and inherits the sockets from systemd
*.lokal names must resolveInstall a systemd-resolved DNS delegate, /etc/systemd/dns-delegate.d/lokal.dns-delegate (sudo), pointing at 127.0.0.1:53535systemd-resolved sends *.lokal lookups to lokal’s DNS server
text
 browser / curl / node
   │
   ├─ 1. resolve api.shop.lokal ──▶ systemd-resolved ──▶ lokal.dns-delegate ──▶ lokal's DNS server (UDP 127.0.0.1:53535)
   │
   └─ 2. HTTPS to 127.0.0.1:443 ──▶ socket held by systemd ──▶ lokal: TLS with a leaf for the SNI ──▶ proxy ──▶ localhost:4000
  • Trust. The CA is copied into the distribution’s folder of extra root certificates, as lokal-<sha1>.crt, and update-ca-certificates (Debian, Ubuntu) or update-ca-trust (Fedora, Arch) rebuilds the system bundle that curl, OpenSSL and most tools read. Chrome, Chromium and Firefox don’t read that bundle, so setup also adds the CA to their own certificate databases, as you and never as root.
  • Ports. Three systemd socket units hold 127.0.0.1 and ::1 on 443 and 80, and UDP 127.0.0.1:53535, and hand them to dev.lokal.daemon.service, which runs lokal daemon run as you. As with launchd, connections that arrive while the service restarts wait in the socket’s queue, and systemd restarts a crashed service after a second (Restart=always, RestartSec=1). A user unit can’t bind ports below 1024 by default, so setup lowers that limit to 80, once. That lets every local user bind ports 80 to 1023, and lokal uninstall sets the default, 1024, back.
  • Names. systemd 258 and later read DNS delegates: the file tells systemd-resolved to send every *.lokal lookup to lokal’s DNS server. Only programs that ask resolved see it, so setup checks that the system’s lookups go through resolved before it changes anything.

Setting up lokal shows the exact commands setup runs on Linux.

Read the code#

ARCHITECTURE.md maps each piece on this page to its module in the code, and describes the platform seam that keeps macOS specifics in one place. The specification has the full detail, and the performance page shows how fast each step is on a real Mac.