Robert Važan

Subdomains on localhost

Web development and local web apps typically require high port numbers like :8080, which can be hard to remember and prone to conflicts. Let's explore a better approach using localhost subdomains instead of port numbers.

No standard solution

There is no system API for claiming localhost subdomains like there is for ports. In the past, I mapped a wildcard subdomain on my public domain (*.test.machinezoo.com) to 127.0.0.1, but that solution has several drawbacks: it's not available to developers without a domain, it’s potentially insecure, it breaks when internet connection is down, and it still requires ports in URLs.

A better solution is to run reverse proxy on port 80 that forwards virtual domains to specific ports. This works well both for web app development and for common local web apps like Syncthing and Open WebUI.

DNS configuration

On my system (Fedora), subdomains like app1.localhost resolve to the same address as plain localhost (::1 for IPv6 and 127.0.0.1 for IPv4), apparently thanks to systemd-resolved. A quick test shows it works both with ping and in Firefox. While this might not work out of the box on all operating systems, it's generally easy to configure.

If you configure system domain resolver manually, you can use any reserved domain to point to localhost. While .test is a good option and .local is often incorrectly used for this purpose, I prefer .localhost, because it is intended for this purpose and the name suits any application.

Choosing reverse proxy

When it comes to reverse proxies, there are three main contenders: Caddy, Traefik, and Nginx. I ruled out Nginx due to its C codebase (a potential security risk), its connection to Russia through some of its most active developers, and the fact that some key features are only available to paying customers.

Between Caddy and Traefik, Caddy emerged as the better choice. Traefik's configuration is notably more verbose and complicated than Caddyfile. Caddy is specifically designed to be easy to configure, and localhost servers are one of its intended uses. While Caddy is less feature-rich than Traefik, it's more than sufficient for our needs.

Port selection strategy

Even with subdomains, each web app still needs a port, to which the reverse proxy can send requests. Rather than making port numbers configurable, which creates extra work for developers and inconvenience for users, I recommend choosing a fixed port randomly in the range between 1,024 and 32,000. Privileged ports under 1,024 and ephemeral ports above 32,000 should be avoided. Conflicts with other applications are unlikely, but if anyone reports one, just choose another random port. Only make ports configurable for popular localhost servers where the effort is warranted.

Caddy configuration

Caddy uses a single configuration file called Caddyfile. Thanks to Caddy's sensible defaults, the configuration can remain quite short. Here's a basic example:

{
    default_bind 127.0.0.1
    admin off
}

http://app1.localhost {
    reverse_proxy localhost:8080
}

http://*.app2.localhost {
    reverse_proxy localhost:8081
}

While I'm not certain whether default_bind is strictly necessary (Caddy might infer it from the .localhost in the site address), it's better to be explicit. I've disabled the admin API to avoid having to analyze its security impact, though some users might want it to be able to reload Caddyfile without restarting Caddy. The http:// prefix in site address ensures that Caddy listens on port 80 only and it does not try to obtain TLS certificates. Caddy supports long-poll and websockets by default without additional configuration.

Plain Caddyfile is somewhat repetitive. While Caddyfile supports snippets for concise definition of multiple domains, I find parameter handling in snippets a bit messy. Instead, I use a short Python script to generate the domain list from subdomain-port pairs:

import textwrap

mappings = {
    8080: "app1",
    8081: "*.app2",
}

print(textwrap.dedent('''
    {
        default_bind 127.0.0.1
        admin off
    }
''').strip())

for port, subdomain in mappings.items():
    print(textwrap.dedent(f'''
        http://{subdomain}.localhost {{
            reverse_proxy localhost:{port}
        }}
    ''').rstrip())

Running Caddy

While Caddy can run directly as a system service (the Caddyfile above is designed for it), containerization provides a cleaner solution with reduced security risk. Here's how to run it with Podman:

sudo podman run -d --name caddy \
    --replace \
    --pull=always \
    --restart=always \
    --network=host \
    --stop-signal=SIGKILL \
    -v /path/to/Caddyfile:/etc/caddy/Caddyfile:z \
    docker.io/caddy
sudo systemctl enable podman-restart

Host networking is essential to allow Caddy to reverse-proxy to localhost ports. The container can be updated by simply rerunning the podman command, and podman-restart ensures Caddy starts after system boot. Since the admin API is disabled and there's no TLS, there's no need for data and config volumes. The SIGKILL stop signal ensures fast shutdown/restart, avoiding the default SIGTERM behavior where Caddy waits for connections to close (which can take a long time with active long-poll requests).

Is it worth it?

Setting up localhost subdomains makes sense in several scenarios:

However, for occasional local testing of web apps, complexity of this setup probably isn't justified.