DEV Community

Cover image for I Built a Local Dev Proxy. Six Months Later, Its Daemon No Longer Runs as Root
Martin Voldřich
Martin Voldřich

Posted on

I Built a Local Dev Proxy. Six Months Later, Its Daemon No Longer Runs as Root

Six months ago, I wrote about why I was tired of developing on URLs like http://localhost:3000 and built Roxy.

The first version had a simple goal: give every local project a memorable
.roxy domain with trusted HTTPS.

http://localhost:3000

became

https://myapp.roxy
Enter fullscreen mode Exit fullscreen mode

It worked, and I started using it every day.

Today, Roxy 1.1.0 is available, and the project finally has a proper home at roxy.rbas.cz. It still does the same simple thing, but the project around that idea has changed quite a bit:

  • Docker Compose services can be discovered automatically
  • Linux is supported alongside macOS
  • wildcard subdomains get trusted HTTPS
  • one domain can route different paths to different services
  • configuration changes are applied without restarting the daemon
  • static directories have a built-in file browser
  • and, most importantly, the daemon no longer runs as root

Here is what changed, why it changed, and what I learned while turning a small experiment into a tool I can rely on.

The daily workflow is still deliberately boring

Roxy is not trying to become a general-purpose reverse proxy. The workflow is
still centered around one command:

roxy register myapp.roxy --route "/=3000"
Enter fullscreen mode Exit fullscreen mode

Open https://myapp.roxy and you are done. Roxy handles local DNS, creates a trusted certificate, and proxies the request to the application on port 3000.

For a full-stack project, several services can share the same domain:

roxy register myapp.roxy \
  --route "/=3000" \
  --route "/api=3001" \
  --route "/admin=8080"
Enter fullscreen mode Exit fullscreen mode

That gives you:

https://myapp.roxy          -> frontend on port 3000
https://myapp.roxy/api      -> API on port 3001
https://myapp.roxy/admin    -> admin app on port 8080
Enter fullscreen mode Exit fullscreen mode

The longest matching path wins, and routes can now be changed after a domain is registered:

roxy route add myapp.roxy /webhooks 9000
roxy route remove myapp.roxy /admin
roxy route list myapp.roxy
Enter fullscreen mode Exit fullscreen mode

The daemon reloads the new configuration immediately. No hand-written proxy configuration and no service restart.

Docker Compose without another routing file

Docker auto-discovery was the biggest addition in Roxy 1.0.

Enable it once in the Roxy configuration:

[docker]
enabled = true
Enter fullscreen mode Exit fullscreen mode

Then start a normal Compose project:

name: myproject

services:
  web:
    build: .
    ports:
      - "3000:3000"

  api:
    build: ./api
    ports:
      - "8080:8080"
Enter fullscreen mode Exit fullscreen mode
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Roxy watches Docker events and exposes the services automatically:

https://web.myproject.roxy
https://api.myproject.roxy
Enter fullscreen mode Exit fullscreen mode

There is no roxy register command and no second configuration file describing the same containers. When a container starts or stops, Roxy updates its routing table.

The default convention can be changed with Compose labels when needed:

services:
  web:
    build: .
    ports:
      - "3000:3000"
    labels:
      roxy.domain: "shop.roxy"
      roxy.wildcard: "true"
Enter fullscreen mode Exit fullscreen mode

I wanted the default case to require no Roxy-specific configuration while keeping an escape hatch for projects that need a stable domain or a specific port.

Wildcard domains became a real requirement

The original exact-domain model was enough until I used Roxy with a
multi-tenant application. Registering every tenant subdomain individually was clearly the wrong workflow.

Now one registration can cover the base domain and its subdomains:

roxy register myapp.roxy --wildcard --route "/=3000"
Enter fullscreen mode Exit fullscreen mode

These URLs then work immediately:

https://myapp.roxy
https://acme.myapp.roxy
https://globex.myapp.roxy
Enter fullscreen mode Exit fullscreen mode

Roxy generates an exact certificate from TLS SNI when a hostname is first requested and keeps it in an in-memory cache. Per-domain private keys are not written to disk.

Roxy now works on Linux

The first release was macOS-only. Roxy now supports Ubuntu and Debian as well.

The user-facing workflow remains the same, but the system integration is native to each platform:

  • macOS uses /etc/resolver and launchd
  • Linux uses systemd-resolved and systemd

The important part is that the platform-specific work stays behind the same CLI.
Developers should not need to know which service file or DNS configuration Roxy
created to run roxy register.

The daemon no longer runs as root

This is the change in Roxy 1.1 that matters most to me.

Local HTTPS normally wants ports 80 and 443, and binding those ports requires privileges. The early version took the straightforward route: the Roxy daemon ran as root.

It worked, but it became increasingly uncomfortable as Roxy gained more responsibilities. A process that reads project directories, watches Docker, loads user configuration, and handles network traffic should not have unlimited system privileges just because it needs two low-numbered ports.

Roxy 1.1 uses socket activation instead:

launchd/systemd owns ports 80 and 443
                |
                v
      passes open listeners to Roxy
                |
                v
       Roxy runs as your user
Enter fullscreen mode Exit fullscreen mode

The one-time installation still needs elevated privileges because it installs the local CA, DNS integration, and system service:

sudo roxy install
Enter fullscreen mode Exit fullscreen mode

After that, routine commands run without sudo:

roxy register myapp.roxy --route "/=3000"
roxy reload
roxy restart
roxy logs -f
roxy status
Enter fullscreen mode Exit fullscreen mode

Configuration, logs, runtime files, and the CA are owned by the developer account. The operating system owns only the privileged listeners and hands them to the unprivileged Roxy process.

This was not a flashy feature, but it made the security model much easier to explain: Roxy has the privileges it needs, and no more.

Upgrading from an older Roxy installation

If you already use a version with the old root daemon, upgrade Roxy and run:

sudo roxy install
Enter fullscreen mode Exit fullscreen mode

The installer imports registrations and the Root CA from /etc/roxy, stops the old service, creates the user-owned configuration, and installs socket activation. It leaves the old /etc/roxy directory in place as a migration backup.

New installations use the same command:

brew tap rbas/roxy
brew install roxy
sudo roxy install
Enter fullscreen mode Exit fullscreen mode

Then register a project:

roxy register myapp.roxy --route "/=3000"
Enter fullscreen mode Exit fullscreen mode

Restart your browser once after the initial installation so it picks up the local certificate authority.

Try Roxy 1.1

Roxy is open source and written in Rust. It runs on macOS and Linux and ships as a single binary with no external runtime dependencies.

If you try it, I would love to hear what works, what breaks, and which part of your local development setup is still more complicated than it should be.

Top comments (0)