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#
- Setup, once per Mac.
lokal setupis the only time anything privileged happens. Each of its one-time steps removes a privilege that would otherwise be needed at runtime. - Daily use, fast and quiet.
projectandservicecommands 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:
| Problem | One-time step in setup | Result at runtime |
|---|---|---|
| Browsers must trust the certificates | Create 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 privileged | Install a LaunchAgent whose plist declares the sockets, so launchd binds them | The background service runs as you and inherits the sockets |
*.lokal names must resolve | Install /etc/resolver/lokal (sudo), pointing at 127.0.0.1:53535 | lokal’s DNS server answers on a UDP socket that launchd holds |
The pieces#
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 logsshows 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
Hostheader, 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
localhostupstreams it tries::1and127.0.0.1in parallel and remembers the winner for 30 seconds, because Node 17+ bindslocalhostto::1only and Python to127.0.0.1only. - 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-HostandX-Forwarded-Port: 443, and appends the client toX-Forwarded-For. With--host-header upstreamit rewritesHostto the upstream’shost:port. It never sendsStrict-Transport-Security. - Errors. If your dev server doesn’t accept a connection within 2 seconds, the proxy returns a
502page that names the upstream. Plain HTTP on port 80 gets a308redirect tohttps://for configured names, and a small404page that lists the project’s URLs for anything else. If a request’sHostbelongs to a different name than the connection’s SNI, which can happen when HTTP/2 reuses a connection, the proxy answers421so 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,reloadorselftest.lokal statusandlokal doctoruse it, and every change ends with areload.
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:
- It takes an exclusive lock on
state.toml.lock, then reads, changes and atomically writesstate.toml(temporary file, fsync, rename). - It sends
reloadto 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. - 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
.lokalanswers. mDNSResponder caches any failed lookup for about 75 seconds. If lokal answered “no such name” for unknown names, a URL you opened beforeservice addwould stay broken for over a minute. So every.lokalname 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. ThrottleInterval1 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:
| Problem | One-time step in setup | Result at runtime |
|---|---|---|
| Browsers and tools must trust the certificates | Create 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 privileged | Install 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 resolve | Install a systemd-resolved DNS delegate, /etc/systemd/dns-delegate.d/lokal.dns-delegate (sudo), pointing at 127.0.0.1:53535 | systemd-resolved sends *.lokal lookups to lokal’s DNS server |
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, andupdate-ca-certificates(Debian, Ubuntu) orupdate-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.1and::1on 443 and 80, and UDP127.0.0.1:53535, and hand them todev.lokal.daemon.service, which runslokal daemon runas 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, andlokal uninstallsets the default, 1024, back. - Names. systemd 258 and later read DNS delegates: the file tells systemd-resolved to send every
*.lokallookup 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.