Motivation#
Let’s say you have a server that runs some services like Pi-hole, Home Assistant etc. Maybe you also have some internal websites you host. It would be nice if you could access these sites from anywhere and if they had nice domain names like pihole.internal.kelp.xyz, but nothing is exposed to the public internet. Our goals are therefore:
Remotely access any service (or website) hosted on our server
Access these services via human-readable domain names e.g.
pihole.internal.kelp.xyzHave valid SSL certificates to avoid seeing browser warnings every time you visit your services
Do the above without exposing these services to the wider internet
We can do this using Tailscale , a local DNS server and a reverse proxy. I assume Tailscale is both installed and running on both the server and your local machine, otherwise follow the Tailscale quickstart guide .
Prerequisites#
We need to know the Tailscale Machine name for our server as well as its local ip address in our home network. The machine name can be found under the Machines tab in the Tailscale admin console. The local ip address is the first address you see when running hostname -I on the server. For the sake of this tutorial assume our server name is slippery-server and it has a local ip address of 192.168.0.123.
We will also need a domain name. In this tutorial, we’ll use the domain kelp.xyz1. If you don’t want to buy a domain name, you can get a free one from DuckDNS with the catch being the resulting domain will be quite long e.g. kelp.duckdns.org. Otherwise the only restriction is that we need our DNS hosting to support the DNS-01 challenge . When you buy a domain, by default the registrar (Namecheap, GoDaddy, etc.) also hosts the DNS server. The easiest approach is to therefore buy a domain from a registrar that supports Let’s Encrypt DNS-01 validation . If you already have a domain from a registrar which doesn’t support DNS-01 validation (like Namecheap), you can simply move the DNS hosting to somewhere that does by adapting the steps from Moving DNS hosting from Namecheap to Cloudflare .
Setup a local service#
We first need to run some service on our server. Feel free to skip this section if you already have all your services setup. We just need something running for this tutorial, so I picked the dummy webserver whoami which will run on port 8080.
Docker install#
We will install our service using Docker, so follow the instructions at Install Docker Engine on Ubuntu (I’m running Ubuntu on my server, adapt the Docker install accordingly for your OS). Don’t forget the Linux post-installation steps for Docker Engine so that non-root users can run docker. Then check everything is installed with docker run hello-world which should output:
>>> docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
[...]Start the service#
We can then run our dummy service whoami with:
docker run -d -p 8080:80 traefik/whoamiWe now have a webserver that prints OS information and HTTP request to output running on port 8080. Without any additional setup we can access this site via any machine on our Tailnet via http://<server-name>:8080/ which in our case is http://slippery-server:8080/, or from inside our local network at http://192.168.0.123:8080/.
Human-readable domain names#
Local DNS Server#
The Domain Name System (DNS) protocol is how we can type liam.vanderpoel.ch into our browser and be routed to the actual IP address where my website is hosted e.g. 37.16.9.210. This occurs because after I bought my domain name I went to my DNS provider and created DNS records that publicly store the mapping liam.vanderpoel.ch to 37.16.9.210. We would like the same thing to occur inside both our Tailnet and our local network, where any request ending in internal.kelp.xyz gets routed to the slippery-server machine. I achieve this by running my own local DNS server2.
Pi-hole#
There are a few options for a local DNS server. If you are already running a Pi-hole , then you already have a local DNS server. Otherwise install dnsmasq . We focus on the configuration with Pi-hole, but as Pi-hole runs dnsmasq under the hood, with either approach we add the following line to our dnsmasq configuration:
address=/internal.kelp.xyz/100.764.629.423where 100.764.629.423 is the Tailscale ip address of the slippery-server machine.
In the Pi-hole admin console under Settings > All Settings > Miscellaneous enable the setting misc.etc_dnsmasq_d (Ensure that you are in Expert mode and not Basic else this option will not be shown). Then add the above line to misc.dnsmasq_lines. Save and apply the changes. We now have that whenever our Pi-hole gets a request ending in internal.kelp.xyz it will route it to the slippery-server machine.
Next we update the ports used by Pi-hole to avoid conflicts with our reverse proxy. We update the docker compose for Pi-hole to use the 8081 port instead of port 80 for HTTP and then remove the "443:443/tcp" line so that the reverse proxy can handle all HTTPS connections:
ports:
- "8081:80/tcp"
# - "443:443/tcp"Then restart the Pi-hole service.
Tailscale DNS Settings#
Next we configure Tailscale to use the DNS server running on the slippery-server machine for anything ending in internal.kelp.xyz. This requires adding a custom nameserver under the DNS tab in the admin console with the Tailscale ip address of slippery-server i.e. 100.764.629.423 to the Nameserver field. Then setup Split DNS so we only use the Pi-hole DNS server for domains ending in internal.kelp.xyz.
We can now test our DNS configuration by running dig anything.internal.kelp.xyz on any device in our Tailnet, and we should see the request get routed to the 100.764.629.423 ip address:
> dig anything.internal.kelp.xyz +short
100.764.629.423We can also verify that in our browser we can additionally access the whoami service at http://internal.kelp.xyz:8080/.
Reverse Proxy#
So far we can access any service from any machine on our Tailnet using our custom subdomain internal.kelp.xyz if we know the port on which each service is running e.g. whoami is running on port 8080, so we type into our browser http://internal.kelp.xyz:8080/. Our goal for this section is to no longer need port numbers, but to instead access our services by chaining subdomains e.g. http://whoami.internal.kelp.xyz. This is the job of our Reverse proxy which routes traffic to the specified service depending on the url.
Caddy setup#
We will use Caddy as our reverse proxy. As I will be using Cloudflare as my DNS host, I use a Docker image for a Caddy server with built-in support for Cloudflare DNS-01 ACME challenges . Following the setup instructions I add to my existing docker-compose.yml:
services:
caddy:
container_name: caddy
image: ghcr.io/caddybuilds/caddy-cloudflare:latest
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ../app-data/caddy/Caddyfile:/etc/caddy/Caddyfile
- ../app-data/caddy/site:/srv
- ../app-data/caddy/data:/data
- ../app-data/caddy/config:/config
environment:
- CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
network_mode: hostreplace your_cloudflare_api_token with your real Cloudflare API token generated by following the Creating a Cloudflare API Token instructions. Ensure you select Zone:Read and DNS:Edit permissions for the domain(s) you’re managing with Caddy.
Then we edit the Caddyfile and add the lines:
{
# Set the ACME DNS challenge provider to use Cloudflare for all sites
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
whoami.internal.kelp.xyz {
reverse_proxy http://localhost:8080
}
pihole.internal.kelp.xyz {
redir / /admin
reverse_proxy http://localhost:8081
}This will now route https://whoami.internal.kelp.xyz to our service on port 8080. We additionally added an entry for our Pi-hole admin console, with an additional redirect from https://pihole.internal.kelp.xyz to https://pihole.internal.kelp.xyz/admin as I don’t want to have to remember to add /admin.
Local access without Tailscale#
Lastly if we are connected to our local network, we want the ability to connect to our services without having to connect to Tailscale. The approach I took was to configure my router to use slippery-server as its default DNS server (I also added a backup DNS server like Cloudflare’s 1.1.1.1 so that I don’t lose my ability to browse the internet if either slippery-server or the Pi-hole container goes down). The added benefit is that the Pi-hole will block ads on my entire local network. I also reserved the ip address 192.168.0.123 in the router so it would not change.
Next, what we want is for our DNS server to route to the internal ip address 192.168.0.123 when we are at home, and the Tailscale ip address 100.764.629.423 when we are away. I tried adding both lines to the dnsmasq configuration like:
address=/internal.kelp.xyz/192.168.0.123
address=/internal.kelp.xyz/100.764.629.423with the idea that when connected to Tailscale away from home, the local ip will fail and we will fallback to using the Tailscale ip. DO NOT DO THIS. I wasted a lot of time trying to get this to work reliably to no avail.
Instead, we can make the local ip address of slippery-server reachable from the Tailnet by setting it up as a subnet router . As we just want to route to the slippery-server machine we use a host route for the subnet /32:
sudo tailscale set --advertise-routes=192.168.0.123/32 --advertise-exit-nodeI want to use my server as an exit node too, if you don’t, leave out the last flag. Then approve this request in the Tailscale admin console under Machines > slippery-server > Subnets.
We then need to update our dnsmasq configuration to route to the local ip address, replace the existing line with:
address=/internal.kelp.xyz/192.168.0.123We can test everything is working by running dig anything.internal.kelp.xyz on any device in our local network (with Tailscale disconnected), and we should see the request get routed to the 192.168.0.123 ip address:
> dig anything.internal.kelp.xyz +short
192.168.0.123and should also be able to access https://whoami.internal.kelp.xyz in the browser. Then while away from home, connect to Tailscale and confirm that you can access https://whoami.internal.kelp.xyz in order to check the subnet routing is working.
We can now access our services both inside our local network and remotely when connected to Tailscale!
References
- Tailscale
- Tailscale quickstart guide
- DuckDNS
- DNS-01 challenge
- whoami
- Domain Name System (DNS)
- Pi-hole
- dnsmasq
- Split DNS
- Reverse proxy
- Caddy
- Caddy server with built-in support for Cloudflare DNS-01 ACME challenges
- Creating a Cloudflare API Token
- subnet router
- exit node
- Why Using a .local Domain for Internal Networks is a Bad Idea
- Easy, quick and free valid SSL certificates for your homelab
- Remotely access and share your self-hosted services
The motivation for buying a domain is to avoid seeing browser warnings every time you visit your services. If we don’t control the domain then we cannot use the DNS-01 challenge to provision SSL certificates. If we didn’t care about SSL warnings, we could use pretty much any domain name we want, so long as it doesn’t conflict with another public website i.e. don’t use
google.com. There are some special domains to avoid, notably.localdomains can cause issues down the line (see Why Using a .local Domain for Internal Networks is a Bad Idea ). ↩︎If you want to instead use a public DNS server I recommend following ideas from Easy, quick and free valid SSL certificates for your homelab and Remotely access and share your self-hosted services . ↩︎