On this page
Some dev servers render many sites from one process, each under a path of its own: a site builder serves
/demosite/acme.com, a preview server serves /preview/<slug>. In production, an edge (a worker, nginx, Caddy)
gives each site its own host and maps it to that path. A route in lokal.toml does the same on your Mac, so each
site gets a .lokal name, and its root-relative links, its cookies and location.host behave as they will in
production.
Add a route#
Routes go in a [routes] table in lokal.toml, next to the services. Each key is a host
pattern, and each value is the URL to send it to:
project = "seopages"
[services]
render = "npm run dev -- --port {port}"
[routes]
"{site}" = "https://render.seopages.lokal/demosite/{site}"lokal up writes the route along with the services, and prints a line for it:
$ lokal up
✓ https://render.seopages.lokal → localhost:51234 npm run dev -- --port 51234
✓ https://{site}.seopages.lokal → https://render.seopages.lokal/demosite/{site}
routes ready in 38ms{site} takes whatever comes before seopages.lokal, and lokal puts it into the path:
| You open | render gets |
|---|---|
https://acme.com.seopages.lokal/ | /demosite/acme.com |
https://acme.com.seopages.lokal/about?x=1 | /demosite/acme.com/about?x=1 |
https://blog.example.org.seopages.lokal/ | /demosite/blog.example.org |
Each site’s name gets its own certificate on its first visit, naming exactly that name, as a name under a wildcard service does.
lokal down removes the routes with the services, and lokal list shows them after the services, marked
(lokal.toml). Routes exist only in lokal.toml: there’s no command that adds one.
Patterns#
A key is a host pattern under the project’s domain (a full name such as {site}.seopages.lokal works too). Each
label is a plain label or a capture, {name}, a whole label whose name is lowercase letters, digits and _,
starting with a letter.
- The leftmost capture takes one or more labels.
{site}coversacme.com.seopages.lokaland takesacme.com. - Every other capture takes exactly one. In
{page}.{tenant},{tenant}is one label and{page}the rest. - A plain label on the left pins the depth.
preview.{slug}coverspreview.x.myservice.lokaland nothing deeper. - The domain itself is never covered.
seopages.lokalstays the"@"service’s, if there is one. - A pattern needs a capture. A key without one is an error that suggests one, and so are
pre-{x},{x}y,{},*and a capture name used twice. A wildcard goes under[services]as"*".
Only real labels are captured: letters, digits and -. A Host header with /, %, ? or an empty label
matches no route, so nothing but a host name ever reaches the target’s path.
Targets#
The value is the target URL, in one of two forms:
- A service in the same file, by its full name:
https://render.seopages.lokal/demosite/{site}. lokal sends the request to that service’s port directly. A wildcard service can’t be a target. - A server lokal doesn’t start, on this Mac:
http://localhost:3000/preview/{slug}(or127.0.0.1, or[::1]).
project = "myservice"
[services]
"@" = { run = "npm run dev", port = 3000 }
[routes]
"preview.{slug}" = "http://localhost:3000/preview/{slug}" # preview.abc.myservice.lokal/x → /preview/abc/xEach {name} in the path is replaced with what that capture took, and it must be one of the key’s captures. A
query, a fragment, a placeholder in the host, or any other host or scheme is an error at the value’s line and
column.
What lokal changes in the request#
- The path.
/goes to the target path exactly (/demosite/acme.com, no trailing slash). Any other path is added to it:/aboutbecomes/demosite/acme.com/about. The query stays as it is. - The
Hostheader is the target’s:render.seopages.lokal, or that service’slocalhost:<port>if it hashost_header = "upstream", andlocalhost:3000for alocalhosttarget.X-Forwarded-Hoststill has the name you opened,acme.com.seopages.lokal. - Redirects come back mapped. If the target redirects into its own path, lokal takes the prefix off, so the
browser stays on the site’s name:
Location: /demosite/acme.com/newarrives as/new, andhttps://render.seopages.lokal/demosite/acme.com/newashttps://acme.com.seopages.lokal/new. Any other redirect passes untouched. - WebSockets, server-sent events and streaming work as they do for any service. If the target isn’t running, the
error page names both:
acme.com.seopages.lokal → render.seopages.lokal/demosite/acme.com is not responding.
Pages only#
lokal never rewrites a response body or a cookie’s path. Every path under a routed name goes to the target under
the prefix, assets included: /assets/app.js on acme.com.seopages.lokal asks render for
/demosite/acme.com/assets/app.js. So the target has to serve the site’s assets under the prefix too, or the page
loads them from another origin, such as https://render.seopages.lokal. Links the page writes should be relative
to the site’s root (/about), as they are in production.
Which one wins#
- A service with its own name:
render.seopages.lokalis always therenderservice. - Otherwise, the most specific pattern, wildcard services and routes alike, comparing labels from the domain
outward: a plain label beats a capture, and a longer pattern beats a shorter one. So
preview.{slug}wins over{site}, as"*.v1"wins over"*".
Two patterns that cover exactly the same names, such as {site} and {tenant}, or {site} and a "*" service,
are an error: lokal couldn’t tell which one a request is for. In the file, the error names both lines. Against a
"*" service you added by hand, lokal up stops before it changes anything:
error: route {site} in lokal.toml and service * cover the same names in seopages.lokal, so lokal couldn't tell which one a request is for
hint: remove the service (lokal service remove '*' -p seopages), or the route from lokal.tomlCheck a route#
lokal doctor checks each route as route.{site}.seopages.lokal: whether its target service still exists, and
whether something listens where it forwards. Its dns. and tls. checks probe the pattern with each capture
replaced by lokal-doctor, such as lokal-doctor.seopages.lokal. A route whose target service you removed by hand isn’t
served until the next lokal up adds the service back.
Coming from Caddy#
The same mappings in a Caddyfile, for comparison. For myservice, {labels.2} is the third label from the right
of preview.abc.myservice.lokal, abc:
preview.*.myservice.lokal {
rewrite * /preview/{labels.2}{uri}
reverse_proxy localhost:3000
}seopages needs a regular expression, because a customer’s domain such as acme.com is two labels and Caddy’s
* matches exactly one:
# in a site block that receives every name under seopages.lokal
@site header_regexp site Host ^(.+)\.seopages\.lokal$
rewrite @site /demosite/{re.site.1}{uri}
reverse_proxy @site localhost:51234lokal also does what these lines don’t: it sends / as /preview/abc rather than /preview/abc/, sets the
target’s Host, and maps redirects back to the name you opened.