Epistemic Status: evergreen tree Evergreen - Unless Tailscale changes things dramatically, this should stay relevant for some time


I spent this weekend setting up a Tailscale tailnet for my various Linux machines and a few other devices, mostly so I can have secure remote SSH access, but also so I can host services internally without having to put them on the Internet and still be able to access them remotely.

I started out wanting to use Headscale, a self-hosted control layer for Tailscale, but in the end I switched to using Tailscale itself for the control layer (I’ll explain why later).

I had some trouble finding a good example of how to set up either of these using Auth0 as my OIDC provider, so I’m going to explain how I got it working for both of them, hopefully it will save someone else (or future me) some time. I hadn’t used either of these services before, so I was coming into it with fresh eyes and using their documentation.

None of this stuff is rocket science, but a walkthrough would always be nice.

Getting Headscale running

Since I wanted to try to self-host, I started out setting up Headscale and the Tailscale client on my machines. I’m using NixOS, so it was relatively simple to do. If you’re working on other distributions, the installation documentation will probably have you covered.

For NixOS, I started by adding this to the configuration of my machine that would host the Headscale server. A lot of the settingssettings here translate directly into configuration settings that Headscale has in its YAML config file.

services.headscale = {
  enable = true;
  address = "0.0.0.0";
  port = 443;
  settings = {
    server_url = "<public-https-url>";
    acme_email = "<email>";
    tls_letsencrypt_hostname = "<public-domain>";
    tls_letsencrypt_challenge_type = "TLS-ALPN-01";
    # We don't want to send logs to Tailscale by accident
    logtail.enable = false;
  };
};
# This needs to be enabled on all the machines that will be added to 
# the tailnet.
services.tailscale.enable = true;
services.headscale = {
  enable = true;
  address = "0.0.0.0";
  port = 443;
  settings = {
    server_url = "<public-https-url>";
    acme_email = "<email>";
    tls_letsencrypt_hostname = "<public-domain>";
    tls_letsencrypt_challenge_type = "TLS-ALPN-01";
    # We don't want to send logs to Tailscale by accident
    logtail.enable = false;
  };
};
# This needs to be enabled on all the machines that will be added to 
# the tailnet.
services.tailscale.enable = true;

This will start up a Headscale server listening on HTTPS, and it will request a SSL certificate from Let’s Encrypt. It also starts up the Tailscale client service.

Using it this way will get the out of the box experience that is described in the Headscale documentation. That documentation also describes how to set up things like the iOS Tailscale app.

Registering a machine involves copying the node key from one to another and adding it on the Headscale host. That’s tedious since it requires access to the Headscale host which might not be possible in a remote set up.

Adding Auth0

Auth0’s documentation isn’t entirely clear on how to set something like this up, so I muddled through. On the Headscale side of things I needed a few values, and I could add them using the following snippet of Nix code1 (again, this maps directly to the YAML config file settings).

services.headscale = {
  # ... other options
  settings = {
    # ... other settings
    oidc = {
      issuer = "";
      client_id = "";
      # I'm using sops-nix to handle the secrets, so I need to
      # configure this secret elsewhere per the sops-nix documentation.
      client_secret_path = config.sops.secrets."auth0/client_secret".path;
      allowed_domains = [ "<public-email-domain>" ];
    };
  };
};
services.headscale = {
  # ... other options
  settings = {
    # ... other settings
    oidc = {
      issuer = "";
      client_id = "";
      # I'm using sops-nix to handle the secrets, so I need to
      # configure this secret elsewhere per the sops-nix documentation.
      client_secret_path = config.sops.secrets."auth0/client_secret".path;
      allowed_domains = [ "<public-email-domain>" ];
    };
  };
};

So, I need an “issuer”, which is a URL, a “client ID” and a “client secret”.

I signed up for a free Auth0 account since I don’t need more than the basic features. Then I created an Application. I went with “Regular Web Application” since it’s probably the closest to what I’m doing here.

Then on the “Settings” tab in the Application, I can set up things like the name of the app, and customize the login page. I can also see the “Domain”, “Client ID”, and “Client Secret”. These match up with the three values I needed. For “Issuer”, I need to add the https:// and the final / on the URLs.

After all of that set up, I add a new machine using the Auth0 flow by running the following command, providing the correct URL to the command:

$ sudo tailscale up --login-server "$PUBLIC_TAILSCALE_URL"
$ sudo tailscale up --login-server "$PUBLIC_TAILSCALE_URL"

Running that command provides a link that takes me through the Auth0 flow and once I’m through, the machine will be added to the tailnet.

Why I switched to Tailscale for the control layer?

One thing I wanted to do was add things like my Home Assistant Yellow and Apple TV to the tailnet. Both of those have Tailscale options, but I didn’t want to have to mess with overriding the control layer URL for either of them. I’m sure it can be done, but eventually I wanted to use some of the features that only Tailscale provides.

So, I switched to using the Tailscale control layer.

First, I needed to create an account. Tailscale doesn’t do any authentication on their own. They delegate to OpenID providers, like Google or Facebook, or a custom OIDC provider that you set up.

To go with the OIDC provider option, I had to set up a WebFinger endpoint on my domain. I was a little confused by Tailscale’s documentation for custom providers, and originally I thought I couldn’t do this, given that my website is statically hosted, but it turns out, since I’m probably going to be the only user, I don’t need a WebFinger service, just an endpoint that returns the correct document that Tailscale is looking for2.

With that /.well-known/webfinger file in place, I could login to Tailscale and create an account and then use the regular documented way of adding nodes to the tailnet.

I needed to remove the Headscale configuration and use tailscale downtailscale down on all the machines I had enrolled with the Headscale control layer, then I could just use tailscale uptailscale up without any URL arguments, and it connected to Tailscale and using my existing account connected the node to my tailnet.

Closing thoughts

I’ve found Tailscale to really be a useful product. A feature like Taildrop is just a nice solution to a common problem, and that it works cross-platform makes it better and easier than using scpscp or similar.

I can see why some people might prefer to use Headscale, and I’m glad that project exists, but for my needs Tailscale works fine.

Footnotes

  1. If you’re using sops-nix like I am here, make sure to set the owner and group of the secrets file to the owner and group of the headscale user.

  2. This is probably an abuse of WebFinger, since the protocol expects dynamic responses, but for my purposes it works fine.