kolaente's Blog

Making Tailscale services available on the public internet with nixos

Published at 2024-10-27

I’ve been using Tailscale to connect all my devices across different networks, which is great, but I wanted to take it a step further. I wanted my self-hosted services to be accessible from both inside and outside my network, using the same domain name. Turns out, it’s quite a ride to get there.

Tailscale Funnel

You might be thinking “hey Tailscale Funnel can do that”. And in principle, you’re right. I’m not using it for two reasons:

  1. It has the host tied to the domain name. I’m running multiple services on a single host and want them all available on their own domain.
  2. It does not allow you to freely configure the domain name - they’re always a subdomain of ts.net.

The Setup

First off, I grabbed a VPS from Hetzner to act as a jump host. This machine is exposed to the internet and has a public IP. On it, I installed HAProxy and Tailscale. The idea was to use this as an entry point for external traffic.

I configured HAProxy to do TCP forwarding only. This way, when a client connects, they’re doing the TLS handshake directly with my NAS (where the service runs), not with the jump host.

This is the config I used for the HAProxy:

{ config, pkgs, ... }:

{
  services.haproxy = {
    enable = true;
    config = ''
      global
        maxconn 4096
        daemon
        log /dev/log local0

      defaults
        option tcplog
        timeout connect 5s
        timeout client 30s
        timeout server 30s

      frontend ft_http
        bind *:80
        default_backend bk_combined

      frontend ft_https
        bind *:443
        default_backend bk_combined

      backend bk_combined
        server server1 nas.tailnet-id.ts.net
    '';
  };
}

Pretty simple - it defines two frontends and one backend which handles all the traffic. HAProxy does tcp only by default.

Then, on the server I have Caddy set up like this:

{ config, pkgs, ... }:

{
  services.caddy = {
    enable = true;
    email = "[email protected]";
    virtualHosts = {
      "photos.example.tld:443" = {
        extraConfig = ''
          reverse_proxy localhost:2283
        '';
      };
    };
  };
}

This reverse proxies every request coming in on photos.example.tld to Immich running on localhost on port 2283.

The DNS Shenanigans

But wait, there’s more! I realized that when I’m on my own network and try to access these services, I’m taking an unnecessary trip out to the internet and back. That’s just inefficient.

Tailscale has this feature where you can set up custom DNS servers. So, I set up a DNS server within my Tailnet.

Here’s the DNS resolver config:

{ pkgs, ... }:
{
  services.bind = {
    enable = true;
    zones = {
      "example.lol" = {
        master = true;
        file = pkgs.writeText "zone-example.lol" ''
          $ORIGIN example.lol.
          $TTL    1h
          @            IN      SOA     ns1 hostmaster (
                                           1    ; Serial
                                           3h   ; Refresh
                                           1h   ; Retry
                                           1w   ; Expire
                                           1h)  ; Negative Cache TTL
                        IN    NS nas

          photos    300   IN      A       100.xxx.xxx.xxx

          nas             IN      A       100.xxx.xxx.xxx

        '';
      };
    };

    extraOptions = ''
        recursion yes;
    '';
  };
}

The key here is the dns record for the photos subdomain resolves to the tailnet address. I have not yet tested what happens when there are subdomains that should be resolved using the public dns nameserver. The IN NS nas entry is important - otherwise Bind9 won’t start. It defines the Nameserver for the current record. This is usually the ip where the dns nameserver is running.

This server resolves queries to the tailscale node IP where the service runs. If you’re outside the network, it resolves to the public IP of the jump host. But if you’re inside the Tailnet, it resolves to the Tailscale IP address of the service.

As a result, when I’m on my laptop at home and access my service, it goes directly through Tailscale. When I’m out and about, it goes through the jump host. But I can use the same domain name and the same TLS certificates in both cases!

Was It Worth It?

I spent about three hours putting this all together, and honestly, I really like the result. Is it overcomplicated? Probably. Could I have done it simpler? Maybe.