A picture of a browser's address bar. The picture is taken at an angle to the screen. It is so close that you can see the individual pixels of the screen which says, "www.website.com"

in Sysadmin

Getting Nice URLs in Tailscale

I’ve been moving towards self-hosting recently to decouple myself from centralized online services. One issue you have when self-hosting is access to your network while you’re off-site. So, I used my self-hosting migration as an excuse to learn more about Zero Trust Networking and mesh-based VPNs like Tailscale. If you’re unfamiliar with Tailscale (or tools like it), they have a very good explanation of the concepts titled, How Tailscale Works.

The Problem

Tailscale let’s you choose your machine name in their system. So I can refer to a host like “mycloud.yak-bebop.ts.net”. But, and this is important, I can only refer to the host in this way.

Now I want to run two services on this host. Let’s say FreshRSS and Jellyfin. Note, I’m using two services in this example, but assume there are more like ten services in practice.

Only one service can listen on TCP port 80 and use a pretty name like “http://mycloud.yak-bebop.ts.net/”, the other service will have to listen on a different port like “http://mycloud.yak-bebop.ts.net:8080/”.

This isn’t the end of the world. I had about 3-4 services running this way for awhile. But remembering the TCP port numbers is annoying. So I set up an HTML dashboard where I could just click a link to a web app or service and not have to remember what all the different ports were.

But, I wanted prettier URLs. Ones that I could remember and not have to look up on a dashboard. So I dug in and tried again. The goal is to be able to say:

  • http://freshrss.yak-bebop.ts.net/
  • http://jellyfin.yak-bebop.ts.net/

Attempted Solutions

Reverse Proxy

This sounds like a classic use case for a reverse proxy like nginx or apache. But, I don’t control the DNS domain of “yak-bebop.ts.net” so I couldn’t use a virtual host configuration like, “http://jellyfin.yak-bebop.ts.net/”.

I’d have to use a domain I controlled, and create the DNS records to point those names to my tailnet’s 100.x.x.x IP addresses. Or, use a wildcard “A” record in a domain I controlled to point to “mycloud.yak-bebop.net”. But this required me to fiddle with an external service. Before I settle on this, is there an easier way?

Tailscale Serve

Tailscale’s serve command is similar to what I want, but not quite. I could reverse proxy one service, but it wouldn’t really help me run multiple services on one host. Plus, it seems this feature is focused on TLS. And I’m running services on top of Tailscale, which already provides encryption, so I’m less interested in TLS in this context.

Universal Docker Mods

I got excited when I saw this blog post about Tailscale’s Universal Docker Mods for linuxserver.io containers. I like what linuxserver.io is doing, and I use their image for FreshRSS.

Alas, after trying to work with it for a couple hours, I couldn’t get it to work with FreshRSS. I’m still not sure why, but I kept getting “Connection Reset” errors when trying to connect to the app. I’m sure the feature works, but I just don’t understand how to use it yet. Or maybe I didn’t know how to configure FreshRSS properly while I did this experiment.

Plus linuxserver.io doesn’t publish an image for Jellyfin. So even if I used this technique for FreshRSS, I don’t think I could do the same thing for Jellyfin.

Eureka

Then I stumbled across this Reddit post, where user, thundranos, posted a docker-compose file that had some syntax that I wasn’t familiar with, network_mode: service:tailscale. Here is an example docker-compose.yml file for FreshRSS using the same technique.

---
version: "2.1"
services:
  freshrss:
    image: "lscr.io/linuxserver/freshrss:latest"
    container_name: freshrss
    environment:
      - PUID=1001
      - PGID=1001
      - TZ=America/Los_Angeles
    volumes:
      - ./data:/config
    restart: unless-stopped
    network_mode: service:tailscale
    depends_on:
      - tailscale

  tailscale:
    image: tailscale/tailscale:latest
    # NOTE: since the container using "network_mode:container_name" shares
    # their network stack with this container, this hostname affects both
    # containers.
    hostname: "freshrss"
    volumes:
      - ./tailscale:/var/run/tailscale
    environment:
      - TS_HOSTNAME=freshrss
      - TS_USERSPACE=true
      - TS_STATE_DIR=/var/run/tailscale
    restart: unless-stopped
Code language: PHP (php)

This configuration worked! 🎉 Once I authenticated the tailscale container to my tailnet, I could use the pretty hostname, “http://freshrss.yak-bebop.ts.net”. (Plus some application-specific configuration within the FreshRSS application itself.)

I tried to figure out what that setting meant, and why I hadn’t noticed it before. Turns out I’m not the only one who wishes the docs were better for this setting. Apparently network_mode: service:tailscale means that the freshrss container shares the same network stack as the tailscale container. I wasn’t sure what that meant until I did an experiment where I changed the hostname setting in the tailscale container to say hostname: tailscale, and then used docker-compose exec freshrss /bin/sh to log into the container and see what its hostname was. When I saw the container named, “freshrss”, say its hostname was “tailscale”, it clicked for me what “shared network stack meant”. It means literally sharing the same network stack. Sure enough, when I ran ip addr in each container I saw that both containers reported having the same IP addres. TIL.

Now you can repeat that exercise with a Jellyfin container and the goal is achieved. Two services, each with their own DNS name, running on a well-known port. And that I can access from anywhere, provided I have an authenticated Tailscale client. So I can finally say:

  • http://freshrss.yak-bebop.ts.net/
  • http://jellyfin.yak-bebop.ts.net/

What are the trade-offs? Tailscale has a limit on their free-tier of 100 “machines”. Each one of these side-car containers running in my docker-compose applications counts as one of those “machines”.

Summary

Thankfully, I’m well below that threshold, and I appreciate Tailscale having such a generous free tier. Especially when learning about Zero Trust Networking concepts and mesh overlay networks. I have a much better idea now of how I would use a side-car container like this when deploying an app in a Kubernetes pod, for example.

So far, I’m pretty happy with this setup. Since this took me so long to figure out, I thought I would share what I learned here in case it helps someone else. Is there an easier way? Let me know! 🙂


Thanks to the following for their review and feedback prior to publishing: Malisa Middlebrooks, Chris Read, Ian Gagorik.

Header image: “macro pixels url cliche” by Cubosh is licensed under CC BY 2.0.
To view a copy of this license, visit https://creativecommons.org/licenses/by/2.0/?ref=openverse.