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.
You might be thinking “hey Tailscale Funnel can do that”. And in principle, you’re right. I’m not using it for two reasons:
ts.net
.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.
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!
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.