Will's avatar

⬅️ See more posts

Tailscale: multi-service HTTPS on a single machine

27 October 2022 (4 minute read)

🔮 This post is also available via Gemini.

technology selfhost

AI generated artwork representing secure network connections.

Update 2024-09-01:
I have since written a note on Tailscale Sidecars which provides a more elegant solution to this problem. I have left this post here for posterity.

HTTPS on Tailscale

Tailscale’s HTTPS feature is an excellent tool for adding TLS connections to web services exposed over the tailnet.

Although traffic over the tailnet is encrypted anyway due to the nature of Tailscale itself, some web-based services work better when served over HTTPS. Since the browser does not know that you are accessing the service over a secure connection, it may enforce limits on connected web services when accessing them in - what feels like - an insecure context.

Enabling HTTPS for Tailscale machines is as simple as running tailscale cert on each machine you want to issue a certificate for. This command will cause Tailscale to request a TLS certificate using Let’s Encrypt for the machine and to write the certificate and key file to the filesystem.

In an ideal world, this is great, since you can then just plug these files into your reverse-proxy in order to add HTTPS capability to your web service.

Limitations in multi-service contexts

However, Tailscale can only issue one certificate per machine. If your tailnet is named “my-tailnet.ts.net” and one of your machines is called “machine1”, then running the command on this machine will generate a certificate for “machine1.my-tailnet.ts.net”. Since Tailscale doesn’t (currently) support machine sub-names (e.g. for “app1.machine1.my-tailscale.ts.net”), then it becomes tricky to use a single reverse-proxy to terminate TLS for several services at once from the single machine.

Reverse-proxy web servers typically support routing based on host (i.e. subdomains), path, headers, and more. But in this case, host-based routing wouldn’t work since there is only one host name for which the certificate is valid. Path-based routing can become a nightmare to manage (and many services do not expect to be served from a non-root path), and header/parameter-based routing is not accessible at all from a number of devices (e.g. mobile browsers).

My experiences

I host a number of personal services on a single VPS, which is a machine on my tailnet. Generally, I do not bother using TLS for these services, since they are happy to be served over plain old HTTP on a port I assign them, and the traffic is secured anyway by Tailscale itself.

However I recently started using 2FAuth as a self-hosted alternative to Authy, and while 95% of it works fine using my normal pattern, browsers block the camera (for scanning QR codes) when working in a non-HTTPS context. Since this is (for me, at least) a crucial part of the 2FA setup experience, this became a major blocker.

I do not want to expose this service outside of my tailnet and so I needed to find a method for serving it over HTTPS using the Tailscale certificate approach.

One option would be to spin-up a new instance (and Tailscale machine) dedicated to the service, but this would not be worth the cost for such a small service. Alternatively, I could make this the only HTTPS-served service on the machine, in which case 2FAuth would be the sole user of the certificate issued for the entire machine. This feels like a dirty and non-scalable approach; for example, I may have other services in the future that also rely on an HTTPS connection.

The solution I went with in the end depends on the fact that the browser doesn’t care which port secure traffic is served from on the machine. In particular, this means that the machine can use the same Tailscale-issued certificate to expose HTTPS traffic simultaneously on ports 8001, 8002, 54321, or anything else. In practice, this would typically involve using a deidcated reverse-proxy server for each service that requires HTTPS, and with each server being provided with the same key and certificate.

To make this work in my case, I simply stuck an nginx container, provided with the certificate and key, in front of the service, as documented in this note. For another service, I would use an additional nginx container, mount the same certificate and key, and then that service would also be served over HTTPS - just on a different port.

To keep things neat and network-separated, I would include the Nginx container definition in the same docker-compose.yml file as the service itself, as also documented in the same note.

Conclusion

I don’t know if this is the most elegant or resource-effective solution, but it is one that works for me for now. I hope that, eventually, Tailscale will be abe to support sub-names for machines in order to improve this experience.

If you’ve come across this issue yourself and found an alternative/better way to manage “virtual hosts” on a single Tailscale machine, then I’d love to hear from you.

✉️ You can reply to this post via email.

📲 Subscribe to updates

If you would like to read more posts like this, then you can subscribe via RSS.