Building a Secure Home Lab with Tailscale, PowerDNS & Traefik’s DNS-01 Challenge
Finally a Secure Home Lab Proxy Setup with NO OPEN PORTS!
Ever wished you could give your Tailscale devices custom domains and automatically secure them with Let’s Encrypt certificates? This guide walks you through standing up Traefik and PowerDNS on your tailnet to make it happen—no exposed ports required!
I’ll assume you’re already familiar with Tailscale—if not, think of it as a VPN that isn’t quite a VPN, powered by WireGuard to weave all your devices into one flat network. This guide skips the basics of installing Tailscale and connecting your machines; if you need that, their official quickstart has you covered.
Create Project Directory Structure
Here we will create our file structure including a shared .env file for both compose files. We will share the same file by creating symbolic links in the powerdns and traefik subdirectories.
mkdir -p /docker/tailscale/
mkdir -p /docker/tailscale/powerdns/
mkdir -p /docker/tailscale/traefik/
touch /docker/tailscale/.env
touch /docker/tailscale/powerdns/docker-compose.yml
touch /docker/tailscale/traefik/docker-compose.yml
cd /docker/tailscale/powerdns
ln -s ../.env .env
cd ../traefik
ln -s ../.env .env
Create Docker Networks
docker network create powerdns-tailscale
docker network create traefik-tailscale
PowerDNS Setup
Create /docker/tailscale/.env
I recommend a minimum 64 character length API Keys for security.
All API keys here were randomly generated.
MYSQL_PASSWORD=z4xjr98M3Qwq4aKY
PDNS_API_KEY=dzss6MExVKZhpS6gd3MyojRCRUtwTroXtXXpf4zMSnmU9MWaSTfBqmtXLvApYo9U
PDNSCONF_API_KEY=SV4hPVRribduPadVF2vkXNdGdhEKaxmEuVvCdS7Bp5Ve5npwj6AxRzbUkfNtNZB7
PDNS_WEBSERVER_PASSWORD=DjJXd6sibMtk7D8i
TAILSCALE_IP=<server_tailscale_ip>
/docker/tailscale/network/.env
Next create /docker/tailscale/powerdns/docker-compose.yml. You'll notice the inclusion of traefik-tailscale network as powerdns needs to communicate with traefik.
networks:
powerdns-tailscale:
external: true
traefik-tailscale:
external: true
volumes:
pdns_mysql:
name: pdns_mysql
services:
db:
image: mariadb:latest
environment:
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
- MYSQL_DATABASE=powerdnsadmin
- MYSQL_USER=pdns
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-INSECURE.PASSWORD}
networks:
powerdns-tailscale:
restart: always
volumes:
- pdns_mysql:/var/lib/mysql
pdns:
image: pschiffe/pdns-mysql
hostname: pdns
domainname: computingforgeeks.com
restart: always
depends_on:
- db
networks:
powerdns:
ports:
- "${TAILSCALE_IP:-127.0.0.1}:53:53/tcp"
- "${TAILSCALE_IP:-127.0.0.1}:53:53/udp"
- "${TAILSCALE_IP:-127.0.0.1}:8082:8081"
environment:
- PDNS_gmysql_host=db
- PDNS_gmysql_port=3306
- PDNS_gmysql_user=pdns
- PDNS_gmysql_dbname=powerdnsadmin
- PDNS_gmysql_password=${MYSQL_PASSWORD:-INSECURE.PASSWORD}
- PDNS_api=yes
- PDNS_api_key=${PDNS_API_KEY:-INSECURE.API.KEY}
- PDNSCONF_API_KEY=${PDNSCONF_API_KEY:-INSECURE.API.KEY}
- PDNS_webserver=yes
- PDNS_webserver-allow-from=127.0.0.1,10.0.0.0/8,172.0.0.0/8,192.0.0.0/24
- PDNS_webserver_address=0.0.0.0
- PDNS_webserver_password=${PDNS_WEBSERVER_PASSWORD:-INSECURE.PASSWORD}
- PDNS_version_string=anonymous
- PDNS_default_ttl=1500
- PDNS_allow_notify_from=0.0.0.0
- PDNS_allow_axfr_ips=127.0.0.1
web_app:
image: powerdnsadmin/pda-legacy:latest
container_name: powerdns_admin
ports:
- "${TAILSCALE_IP:-127.0.0.1}:8080:80"
depends_on:
- db
restart: always
networks:
powerdns-tailscale:
traefik-tailscale:
logging:
driver: json-file
options:
max-size: 50m
environment:
- SQLALCHEMY_DATABASE_URI=mysql://pdns:${MYSQL_PASSWORD:-J5SJP6fQKwGiGeX}@db/powerdnsadmin
- GUNICORN_TIMEOUT=60
- GUNICORN_WORKERS=2
- GUNICORN_LOGLEVEL=DEBUG
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=traefik-tailscale'
- 'traefik.http.routers.dns-admin.rule=Host(`dns.ts.mydomain.com`)'
- 'traefik.http.routers.dns-admin.entryPoints=https'
- 'traefik.http.routers.dns-admin.tls=true'
- 'traefik.http.routers.dns-admin.tls.certresolver=dns01-tailscale'
- "traefik.http.routers.dns-admin.service=whoami-entrypoint"
- 'traefik.http.services.dns-admin-entrypoint.loadbalancer.server.port=80'
/docker/tailscale/network/docker-compose.yml
Bring Up PowerDNS
docker compose up -d
Now that we're up and running, go to PowerDNS Admin's Web UI http://your_tailscale_ip:8080
and click Create and account
.
Create your admin account
Now log in to PowerDNS Admin using your new credentials and you will be taken to the dashboard. You'll have to enter your PowerDNS API Key you defined in your .env file. API URL will be http://pdns:8081
.
Now we click +Create Zone and create our tailscale domain. If you have your own domain, you can create a subdomain. In this case, we will pretend we own mydomain.com
, so we will create a zone called ts.mydomain.com
.
Now click on our new domain in the dashboard.
Next we will click +Add Record and enter our server's tailscale IP with nothing under name as an A record. This will define ts.yourdomain.com as your IP address.
To complete this setup, we will enter a wildcard CNAME record pointing to ts.yourdomain.com.
Click save on the record itself, then Save Changes as well. Your dashboard should look like this now.
At this point your basic PowerDNS configuration is complete. This dashboard will allow you to manage your Tailscale DNS as well. You could define all your machines manually in here with their own entries as well, but that is outside the scope of this tutorial.
DNS-01 Challenge
The DNS-01 challenge verifies domain ownership by requiring a specific TXT record under _acme-challenge.yourdomain.com
. With Traefik, this is automated using your DNS provider’s API. Traefik adds the token, the ACME server verifies it, and if valid, issues the certificate. This is perfect for our situation, because we can not do a http validation with our services only accessible via our tailnet.
Configure Traefik for DNS-01 Challenge
Traefik has a list of dns providers that includes all the heavy hitters including EasyDNS, Duck DNS, Digital Ocean, Vultr, Linode, Hetzner, and the list goes on. The only changes you'll make from this tutorial are the environment variables and provider name. Since I currently host my DNS with Linode, I'll go that route.
To configure DNS Challenge, we will add the following directives to the command:
directive under the traefik service in our docker compose file.
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge=true"
Enable DNS Challenge
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge.provider=linode"
Set Linode as our DNS Challenge Provider
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge.resolvers=92.123.94.2:53,92.123.94.3:53"
Use linode's ns1 and ns2 dns servers to resolve the txt records.
- "--certificatesresolvers.dns01-tailscale.acme.email=le@mydomain.com"
Set our certificate Email Address.
- "--certificatesresolvers.dns01-tailscale.acme.storage=/letsencrypt/acme.json"
Set the storage path of our certificates. We will set this as a volume so we can easily access certificate data.
We also need to set some environment variables to get it all working. LINODE_TOKEN is our Personal Access Token we obtain from Linode's Cloud Manager. We will disable CNAME support as this can cause issue with split DNS by setting LEGO_DIABLE_CNAME_SUPPORT=true
.
environment:
- "LINODE_TOKEN=${LINODE_API_KEY}"
- "LEGO_DISABLE_CNAME_SUPPORT=true"
To obtain our wildcard cert for *.ts.mydomain.com, we create a router using docker labels:
labels:
- "traefik.enable=true"
- "traefik.http.routers.wildcard.tls.certresolver=dns01-tailscale"
- "traefik.http.routers.wildcard.tls.domains[0].main=ts.mydomain.com"
- "traefik.http.routers.wildcard.tls.domains[0].sans=*.ts.mydomain.com"
Fully Put Together docker-compose.yml for Traefik
networks:
traefik-tailscale:
external: true
services:
traefik:
image: "traefik:v3.3"
container_name: "traefik-tailscale"
command:
- "--api=true"
- "--api.dashboard=true"
- "--log=true"
- "--log.level=DEBUG"
- "--log.filepath=/logs/traefik.log"
- "--accesslog"
- "--accesslog.filepath=/logs/access.log"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.file.directory=/dynamic"
- "--entryPoints.http.address=:80"
- "--entryPoints.http.http.redirections.entryPoint.to=https"
- "--entryPoints.http.http.redirections.entryPoint.scheme=https"
- "--entryPoints.https.address=:443"
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge=true"
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge.resolvers=92.123.94.2:53,92.123.94.3:53"
- "--certificatesresolvers.dns01-tailscale.acme.dnschallenge.provider=linode"
- "--certificatesresolvers.dns01-tailscale.acme.email=le@mydomain.com"
- "--certificatesresolvers.dns01-tailscale.acme.storage=/letsencrypt/acme.json"
networks:
traefik-tailscale:
ports:
- "${TAILSCALE_IP}:80:80"
- "${TAILSCALE_IP}:443:443"
- "${TAILSCALE_IP}:8089:8080"
environment:
- "LINODE_TOKEN=${LINODE_API_KEY}"
- "LEGO_DISABLE_CNAME_SUPPORT=true"
volumes:
- "./logs:/logs"
- "./letsencrypt:/letsencrypt"
- "./dynamic:/dynamic"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
labels:
- "traefik.enable=true"
- "traefik.http.routers.wildcard.tls.certresolver=dns01-tailscale"
- "traefik.http.routers.wildcard.tls.domains[0].main=ts.mydomain.com"
- "traefik.http.routers.wildcard.tls.domains[0].sans=*.ts.mydomain.com"
whoami:
image: "traefik/whoami"
container_name: "simple-service"
networks:
traefik-tailscale:
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=traefik-tailscale'
- 'traefik.http.routers.whoami.rule=Host(`whoami.ts.mydomain.com`)'
- 'traefik.http.routers.whoami.entryPoints=https'
- 'traefik.http.routers.whoami.tls=true'
- 'traefik.http.routers.whoami.tls.certresolver=dns01-tailscale'
- "traefik.http.routers.whoami.service=whoami-entrypoint"
- 'traefik.http.services.whoami-entrypoint.loadbalancer.server.port=80'
Now start up the container:
docker compose up -d
Configuring Services
Now under any new docker container run on the same machine as our traefik proxy we can use labels to proxy it.
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=traefik-tailscale'
- 'traefik.http.routers.my-service.rule=Host(`my-service.ts.mydomain.com`)'
- 'traefik.http.routers.my-service.entryPoints=https'
- 'traefik.http.routers.my-service.tls=true'
- 'traefik.http.routers.my-service.tls.certresolver=dns01-tailscale'
- "traefik.http.routers.my-service.service=whoami-entrypoint"
- 'traefik.http.services.my-service-entrypoint.loadbalancer.server.port=80'
You must ensure you do not have duplicate labels so replace my-service
with something unique for each container and also to set the port number of the service.
For services running on other machines in your tailnet, simply drop my-service.yml in /docker/tailscale/traefik/dynamic
following this format:
http:
routers:
my-service-https:
entryPoints:
- https
rule: Host(`my-service.ts.mydomain.com`)
service: my-service-https
tls:
certResolver: dns01-tailscale
services:
my-service-http:
loadBalancer:
servers:
- url: https://my-service.ts.mydomain.com
my-service-https:
loadBalancer:
servers:
- url: http://tailscale_ip:80
Again, make sure to replace my-service, tailscale_ip, and the port number and also name it a unique name
Set Up Split DNS
Split DNS allows your computers to use a specific nameserver only for a domain or subdomain of your choosing.
Tailscale Admin Console
The last piece of the puzzle is setting up Tailscale's Split DNS. To do this, go to your tailscale dns settings, click on Add Nameserver
and click Custom...
Put the Tailscale IP address of the server hosting PowerDNS in the nameserver field, click restrict to domain, and put ts.mydomain.com
into the domain field.
Client Setup
Now that we have everything set up on the server side, we need to make sure the clients are using this new setup. This varies depending on OS.
For Linux add the --accept-dns
flag onto your tailscale up command. You can combine it with other flags as documented here.
sudo tailscale up --accept-dns
For Windows, right click the tailscale icon on your taskbar, hover over preferences, and ensure Use Use Tailscale DNS settings
is checked.
For Mac, you can use the same instructions as found here for the command line.
Conclusion
By wiring together Tailscale, PowerDNS for private DNS, Traefik’s reverse proxy and DNS-01 challenge via Linode DNS, you get automatic Let’s Encrypt TLS and a truly zero-open-ports home lab. Enjoy seamless tailnet domains and rock-solid security—no firewall holes required!