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...

Add a custom DNS server to your tailnet

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!