Using Caddy and .localhost for Local HTTPS and Subdomain Support on Linux

Replacing puma-dev when migrating from macOS to Linux

Posted by Tobias L. Maier on October 27, 2024

The Challenge: Setting Up a Local Environment with Subdomain Support

Recently, I made the switch from my old MacBook to a new Framework 13 Laptop, taking a small step out of the Apple ecosystem. For my new machine, I chose to install Bluefin, an opinionated Linux distribution based on Fedora Silverblue, known for its use of containers and Homebrew. I was pleasantly surprised by how straightforward the initial setup was. However, I encountered a significant hurdle that’s common when moving from macOS to Linux: how to replicate the functionality of puma-dev.

Puma-dev is a fantastic tool for web development, providing local domains with HTTPS and subdomain support out of the box. It’s particularly useful for applications like my main project, Librario, which heavily relies on subdomains. While puma-dev works perfectly on macOS and theoretically supports Linux, I struggled to get it running properly on my new Linux setup. This led me to search for an alternative solution that would provide similar functionality in my new environment.

The Solution: Caddy and .localhost

After some research and experimentation, I found an elegant solution using two key components:

  1. Caddy, a simple yet powerful web server with built-in HTTPS support.
  2. The .localhost top-level domain (TLD), instead of the .test TLD used by puma-dev.

The switch to .localhost is crucial because it’s treated as a special top-level domain which itself, including its subdomains, always resolves to 127.0.0.1. This behavior simplifies our setup significantly.

Here’s how I set it up:

1. Installation

Thanks to Bluefin’s support for Homebrew (which felt familiar coming from macOS), installing Caddy was as simple as running:

brew install caddy

2. Configuring Caddy

In my previous setup with puma-dev, I had the following configuration:

echo 5000 > ~/.puma-dev/library           # enabled https://*.library.test
echo 9000 > ~/.puma-dev/s3.library        # enabled https://s3.library.test
echo 9001 > ~/.puma-dev/admin.s3.library  # enabled https://admin.s3.library.test
echo 8025 > ~/.puma-dev/mailcatcher.library # enabled https://mailcatcher.library.test

To replicate this functionality with Caddy, I created a Caddyfile to handle my local development needs. This configuration essentially translates my previous puma-dev setup to Caddy. Here’s what it looks like:

# Specific subdomains
s3.library.localhost {
    reverse_proxy localhost:9000
}

admin.s3.library.localhost {
    reverse_proxy localhost:9001
}

mailcatcher.library.localhost {
    reverse_proxy localhost:8025
}

# Default catch-all for *.library.localhost
*.library.localhost {
    reverse_proxy localhost:5000
}

This configuration allows Caddy to act as a reverse proxy, forwarding requests to the appropriate local ports based on the subdomain. Note that all domains end with .localhost, which ensures they resolve to 127.0.0.1.

3. Allowing Caddy to Use Privileged Ports

By default, non-root processes can’t bind to ports below 1024 on Linux, a security measure that differs from macOS. To allow Caddy to use ports 80 and 443, I used the setcap command:

sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which caddy))

The readlink -f command is necessary here because which caddy returns a symlink path. The readlink -f resolves this symlink to the actual executable path. This is important because setcap needs to modify the actual binary, not the symlink. However, this also means that this command may need to be re-applied when caddy gets updated.

4. Starting the Caddy Service

With the configuration in place, I started Caddy as a service using Homebrew:

brew services start caddy

It’s worth noting that when Caddy starts for the first time, it attempts to install its root certificate into the system’s local trust store(s). This automatic installation of the root certificate simplifies our HTTPS setup significantly.

5. Testing the Setup

To verify that everything was working correctly, I used curl:

curl https://foo.library.localhost

This command worked out of the box, indicating that Caddy was successfully generating and using a self-signed certificate, installed the root certificate on the system and that .localhost was correctly resolving to 127.0.0.1.

6. Trusting the Self-Signed Certificate in Firefox

While curl worked fine due to Caddy’s automatic certificate installation, I noticed that Firefox (installed as a Flatpak) didn’t trust the self-signed certificate generated by Caddy. This is because Flatpak applications run in a sandboxed environment with their own certificate store, separate from the system’s. As a result, the Flatpak version of Firefox doesn’t have access to the system’s trust store where Caddy’s self-signed certificate is recognized.

To resolve this, I needed to add Caddy’s root certificate to Firefox’s certificate store manually:

  1. I located Caddy’s root certificate:
    /home/linuxbrew/.linuxbrew/var/lib/caddy/pki/authorities/local/root.crt
    
  2. In Firefox, I navigated to Settings > Privacy & Security > Certificates > View Certificates.

  3. In the Certificate Manager, under the “Authorities” tab, I clicked “Import” and selected the Caddy root certificate.

  4. I made sure to check the box to trust this CA for identifying websites.

After restarting Firefox, it now trusts the Caddy-generated certificates for my local domains.

Conclusion

With this setup, I now have a functioning local environment on Linux that supports HTTPS and subdomains, crucial for developing and testing my SaaS application. The combination of Caddy and the .localhost TLD provides a robust alternative to puma-dev on Linux systems.

While it required a bit more manual configuration than I was used to with puma-dev on macOS, this solution offers great flexibility and works seamlessly across different Linux distributions. The use of .localhost as the TLD simplifies DNS resolution, making the setup more straightforward and reliable.