Hostnames and Let’s Encrypt with Traefik

I was tired of memorizing IP:port combinations for everything on my home network: Proxmox on :8006, pfSense on :9443, Kimai (my daily time tracker), Calibre-Web, Home Assistant, Pi-hole. This post documents how I put Traefik in front of all of them, with a real Let’s Encrypt wildcard certificate, so every service now lives at a clean hostname like https://kimai.home.example.com with a valid padlock, at home and remotely through Twingate.

It also documents the detours, because that’s what I’ll need if I ever rebuild this.

This post is anonymized: example.com stands in for my real domain, and all IP addresses, ports and VLAN numbers are illustrative. No secrets appear here; the API token, dashboard password and email are placeholders.


The end result

Hostname Service Backend
pve.home.example.com Proxmox https://192.168.10.10:8006
pfsense.home.example.com pfSense https://192.168.10.30:9443
pihole.home.example.com Pi-hole v6 https://192.168.10.53
kimai.home.example.com Kimai http://192.168.10.40
calibre.home.example.com Calibre-Web http://192.168.10.41:8083
ha.home.example.com Home Assistant http://192.168.10.42:8123
traefik.home.example.com Traefik dashboard (built in, password-protected)
  • One wildcard certificate (*.home.example.com) from Let’s Encrypt, renewed automatically.
  • No ports opened on my router. Let’s Encrypt never connects to my network.
  • Hostnames keep working even if my ISP is down (Pi-hole answers them locally).
  • Works remotely through Twingate with the same URLs.

My network (for context)

Internet
   │
Asus mesh router (AiMesh pair) ── LAN 192.168.10.0/24, gateway 192.168.10.1
   │     DNS 1 → Pi-hole (192.168.10.53), DNS 2 → public filtering resolver
   │
   ├── Mac, Wi-Fi clients
   ├── Pi-hole, Kimai, Calibre-Web, Home Assistant ...
   └── Proxmox host (192.168.10.10)
          ├── Traefik LXC (192.168.10.20)   ← new
          └── pfSense VM (WAN 192.168.10.30)
                 ├── VULN_RANGE    10.10.10.0/24 (VLAN 10)
                 ├── ISOLATED      10.20.20.0/24 (VLAN 20)
                 └── AD_LAB        10.30.30.0/24 (VLAN 30)

One thing I learned while doing this: my “internal network” is actually the Asus LAN, upstream of pfSense. pfSense’s WAN sits on the Asus LAN, and only the pen-testing ranges live behind it. That made Traefik simpler: it sits on the Asus LAN next to the services, and pfSense isn’t involved at all. I deliberately kept the pen-test VLANs out of Traefik so it can’t become a bridge across isolation boundaries I built on purpose.

How a request flows

Browser → kimai.home.example.com
  → DNS (Pi-hole local record, or Cloudflare public record) → 192.168.10.20
  → Traefik terminates TLS with the wildcard cert
  → matches Host header → forwards to http://192.168.10.40

Key design decisions

  1. DNS-01 challenge, not HTTP-01. Traefik proves domain ownership by creating a temporary TXT record via my DNS provider’s API. Nothing on my network needs to be reachable from the internet.
  2. A wildcard certificate. Every certificate issued is published in public Certificate Transparency logs. A wildcard reveals only *.home.example.com, not my individual service names.
  3. A dedicated domain for the homelab, so experiments can never break a domain that carries a website or email.
  4. Traefik as a native binary, not Docker. I’m only using Traefik’s file provider (my services live on other hosts), so Docker added nothing but a layer that broke (see Step 4).
  5. Belt-and-braces DNS: a public wildcard record in Cloudflare and a local wildcard in Pi-hole.

Step 1: A dedicated domain, with DNS on Cloudflare

Why not use Namecheap’s API?

All my domains are registered at Namecheap. Traefik supports Namecheap for DNS-01, but I decided against it:

  • API access is gated. You need 20+ domains, a $50 balance, or $50 spent in the last two years.
  • There are no scoped keys. The key can manage your entire account, including every domain and anything billable.
  • IP whitelisting is required. If my ISP changes my home IP, renewals silently fail about 60 days later.
  • Every change rewrites all host records, including MX records, which is risky on any domain that carries email.

All of my existing domains carry websites or email, so I bought a new one just for the homelab: example.com.

Registration at Namecheap, DNS at Cloudflare

I kept registration at Namecheap (all my domains in one place) and moved only the DNS hosting to Cloudflare’s free plan, which supports API tokens scoped to a single zone.

  1. Buy the domain at Namecheap (skip the add-ons).
  2. In Cloudflare: Add a domain → Free plan. Cloudflare scans existing records.
  3. Delete everything the scan imported. A brand-new Namecheap domain comes with defaults: a parking-page A record, a www CNAME, five eforward* MX records and an SPF TXT record for Namecheap’s email forwarding.
  4. Add anti-spoofing records. A domain with no email can still be forged as a sender:
Type Name Content Notes
MX @ . Priority 0 (“null MX”: accepts no mail)
TXT @ v=spf1 -all Nobody may send as this domain
TXT _dmarc v=DMARC1; p=reject; Reject anything that fails
  1. At Namecheap, change the nameservers in Domain List → Manage → Domain tab → Nameservers → Custom DNS, and enter the two Cloudflare nameservers. ⚠️ Not the Advanced DNS tab. Adding NS records there creates records inside Namecheap’s zone instead of delegating the domain. I made this mistake first.
  2. Wait for Cloudflare’s “now active” email. For a brand-new domain this took minutes!
  3. Create an API token: My Profile → API Tokens → Create Token → “Edit zone DNS” template, with Zone Resources set to Specific zone → example.com. Store it in a password manager; Cloudflare shows it only once.

Side note:

  • I was surprised Cloudflare knew within 5 minutes of purchase that the domain was at Namecheap.
  • It’s all public: the registry’s WHOIS/RDAP data names the registrar, and the .com zone publishes the nameserver delegation within minutes.

Step 2: DNS records for the hostnames

Public wildcard in Cloudflare

Type Name Content Proxy
A *.home 192.168.10.20 DNS only (grey cloud)

Cloudflare labels this “DNS only – reserved IP”, which is fine.

Why publish a private IP publicly?

  • My Asus forwards DNS to Pi-hole (DNS 1) with a public filtering resolver as backup (DNS 2).
  • The router doesn’t strictly treat DNS 2 as a backup and may send some queries there even when Pi-hole is up.
  • If the record existed only in Pi-hole, those queries would get “no such domain” and services would fail at random.
  • With the public record, every resolver gives the same answer.
  • The only thing it reveals is that *.home.example.com points to an unreachable private IP.

Testing

dig +short test2.home.example.com @your-ns1.ns.cloudflare.com   # authoritative
dig +short test2.home.example.com @1.1.1.1                   # public resolver
dig test2.home.example.com @<filtering-resolver-ip>            # backup resolver (full output)

All three returned the Traefik IP.

Gotcha: negative caching.

  • My first test against the backup resolver returned nothing because I’d queried before creating the record, and the resolver cached the “doesn’t exist” answer.
  • Since the record is a wildcard, testing with a fresh name (test2., test3. …) sidesteps the cache.

(Pi-hole also gets a local wildcard in Step 8, for internal ‘resolution’ and ISP outages.)


Step 3: The Traefik LXC on Proxmox

Debian 12, not 13

Side note: A refresher on Linux Container templates

My first attempt with the Debian 13 template failed:

TASK ERROR: unable to create CT 100 - unsupported debian version '13.6'

My Proxmox host was on an older 8.x release, whose container tooling predates Debian 13. (I also discovered that Proxmox VE 8 reached end of life on August 31, 2026. Upgrading to 9.x is now on my to-do list.) The Debian 12 template works fine; this container is just a thin host for Traefik.

Container settings

  • General: hostname traefik, Unprivileged ticked, root password and SSH key
  • Template: debian-12-standard
  • Disk: 8 GB · CPU: 1 core · RAM: 1024 MB, swap 512 MB
  • Network: same bridge as pfSense’s WAN NIC (vmbr0), IPv4 DHCP
  • Options: Start at boot = Yes

Getting a fixed IP: an Asus warning

I tried to assign a specific IP (.25) to the container’s MAC address in the Asus DHCP settings. After saving, both mesh units turned red, Wi-Fi disappeared and the network went down. This has happened to me before, and last time I panicked and factory-reset the router.

What fixed it this time:

  • power-cycling the main router first,
  • waiting for its LED to turn white (3–5 minutes),
  • then the AiMesh node.

What I do now instead:

  • let the container take whatever DHCP gives it,
  • then in LAN → DHCP Server, accept that already-assigned IP as the reservation.
  • That has never caused the problem.
  • The container got 192.168.10.20, and I pointed the Cloudflare record there.

Also:

  • I now keep a router config backup (Administration → Restore/Save/Upload Setting → Save setting), so a reset would no longer mean rebuilding everything by hand.

Step 4: Installing Traefik natively (and why not Docker)

The Docker attempt

I originally installed Docker in the LXC (with nesting and keyctl enabled), but every container failed:

docker: Error response from daemon: failed to create task for container: ...
open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8: permission denied

This is a known break between newer containerd/runc releases and Docker inside Proxmox LXC containers, fixed on the host side in Proxmox 9.1.1.

  • My 8.x host won’t get that fix.
  • The workarounds (downgrading containerd, or disabling AppArmor for the container) were either messy or weakened isolation on the box that fronts all my services.

Since Traefik is a single Go binary and I only use its file provider, I dropped Docker entirely:

apt purge -y docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin docker-ce-rootless-extras
apt autoremove -y
rm /etc/apt/sources.list.d/docker.list

Install the binary (with checksum verification)

cd /tmp
VER=$(curl -s https://api.github.com/repos/traefik/traefik/releases/latest | grep -Po '"tag_name": "\K[^"]+')
echo $VER
curl -LO "https://github.com/traefik/traefik/releases/download/${VER}/traefik_${VER}_linux_amd64.tar.gz"
curl -LO "https://github.com/traefik/traefik/releases/download/${VER}/traefik_${VER}_checksums.txt"
grep "linux_amd64.tar.gz" traefik_${VER}_checksums.txt | sha256sum -c -   # must say OK
tar -xzf traefik_${VER}_linux_amd64.tar.gz traefik
install -m 755 traefik /usr/local/bin/traefik
traefik version    # v3.7.14 at time of writing

Dedicated user and folders

useradd --system --no-create-home --shell /usr/sbin/nologin traefik
mkdir -p /etc/traefik/dynamic /etc/traefik/acme
touch /etc/traefik/acme/acme.json
chown -R traefik:traefik /etc/traefik/acme
chmod 700 /etc/traefik/acme
chmod 600 /etc/traefik/acme/acme.json

Step 5: Configuration

Cloudflare token (kept out of shell history)

read -s -p "Cloudflare API token: " T; echo
echo "CF_DNS_API_TOKEN=$T" > /etc/traefik/traefik.env; unset T
chmod 600 /etc/traefik/traefik.env

Static config: /etc/traefik/traefik.yml

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt
        domains:
          - main: "*.home.example.com"

api:
  dashboard: true

providers:
  file:
    directory: /etc/traefik/dynamic
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      storage: /etc/traefik/acme/acme.json
      # For first-time testing, add the staging CA here (see Step 7):
      # caServer: https://acme-staging-v02.api.letsencrypt.org/directory
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"

log:
  level: INFO

The resolvers setting makes Traefik check challenge propagation against public DNS rather than Pi-hole, which answers *.home.example.com locally.

Routes: /etc/traefik/dynamic/services.yml

http:
  routers:
    dashboard:
      rule: "Host(`traefik.home.example.com`)"
      entryPoints: [websecure]
      service: api@internal
      middlewares: [dashboard-auth]
    proxmox:
      rule: "Host(`pve.home.example.com`)"
      entryPoints: [websecure]
      service: proxmox
    pfsense:
      rule: "Host(`pfsense.home.example.com`)"
      entryPoints: [websecure]
      service: pfsense
    pihole:
      rule: "Host(`pihole.home.example.com`)"
      entryPoints: [websecure]
      service: pihole
      middlewares: [pihole-admin]
    kimai:
      rule: "Host(`kimai.home.example.com`)"
      entryPoints: [websecure]
      service: kimai
    calibre:
      rule: "Host(`calibre.home.example.com`)"
      entryPoints: [websecure]
      service: calibre
    homeassistant:
      rule: "Host(`ha.home.example.com`)"
      entryPoints: [websecure]
      service: homeassistant

  middlewares:
    dashboard-auth:
      basicAuth:
        users:
          - 'admin:$2y$05$...bcrypt-hash...'
    pihole-admin:
      redirectRegex:
        regex: "^https://pihole\\.home\\.example\\.com/?$"
        replacement: "https://pihole.home.example.com/admin/"

  services:
    proxmox:
      loadBalancer:
        serversTransport: selfsigned
        servers: [{ url: "https://192.168.10.10:8006" }]
    pfsense:
      loadBalancer:
        serversTransport: selfsigned
        servers: [{ url: "https://192.168.10.30:9443" }]
    pihole:
      loadBalancer:
        serversTransport: selfsigned
        servers: [{ url: "https://192.168.10.53" }]
    kimai:
      loadBalancer:
        servers: [{ url: "http://192.168.10.40" }]
    calibre:
      loadBalancer:
        servers: [{ url: "http://192.168.10.41:8083" }]
    homeassistant:
      loadBalancer:
        servers: [{ url: "http://192.168.10.42:8123" }]

  serversTransports:
    selfsigned:
      insecureSkipVerify: true   # Proxmox, pfSense and Pi-hole use self-signed certs

Traefik watches this folder, so edits apply within seconds without a restart.

Setting the dashboard password (prompted, so it stays out of shell history):

apt install -y apache2-utils
read -s -p "Dashboard password: " PW; echo
HASH=$(htpasswd -nbB admin "$PW"); unset PW
sed -i -E "s#^( *)- '(admin:.*|DASHBOARD_USER_HASH)'#\1- '$HASH'#" /etc/traefik/dynamic/services.yml

Username:

  • admin
  • Save the password in a password manager right away; the prompt hides your typing, and I lost track of mine on the first try.

YAML gotcha:

  • serversTransport:selfsigned (no space after the colon) is not a key/value pair, and Traefik rejects the file.
  • A tell is that the editor’s syntax highlighting doesn’t colour the line.

systemd service: /etc/systemd/system/traefik.service

[Unit]
Description=Traefik reverse proxy
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=traefik
Group=traefik
EnvironmentFile=/etc/traefik/traefik.env
ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/etc/traefik/acme

[Install]
WantedBy=multi-user.target

Traefik runs as an unprivileged user, may bind only ports 80 and 443, and can write only to the certificate folder. The sandboxing options worked fine in an unprivileged LXC with nesting enabled. If you see status=226/NAMESPACE, remove the Protect*, PrivateTmp and ReadWritePaths lines.


Step 6: Per-service preparation

Home Assistant

  • returns 400 Bad Request behind a proxy until it trusts it.
  • HAOS’s console is a restricted ha shell, so the easy route is the File editor add-on (Settings → Add-ons → Add-on Store → File editor).
  • Add this to configuration.yaml (merge into any existing http: section rather than duplicating the key):
http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 192.168.10.20

Then Developer tools → YAML → Check configuration → Restart.

pfSense

  • blocks unfamiliar hostnames as a “DNS rebind attack”.
  • Go to System → Advanced → Admin Access → Alternate Hostnames and add pfsense.home.example.com.
  • If your WAN rule for the web GUI port restricts the source IP, also allow the Traefik IP.

Pi-hole v6

  • serves its UI over HTTPS.
  • Pointing Traefik at http:// gave a Bad Gateway.
  • Testing from the Traefik LXC showed only HTTPS answered (a 302 is a redirect, not an error):
curl -sI  http://192.168.10.53/admin/  | head -n 1   # nothing
curl -skI https://192.168.10.53/admin/ | head -n 1   # HTTP/1.1 302

So the Pi-hole backend uses https:// with the selfsigned transport.


Step 7: Start, test with staging, then go live

Test with Let’s Encrypt’s staging CA first (uncomment caServer in traefik.yml), so debugging doesn’t burn production rate limits.

systemctl daemon-reload
systemctl enable --now traefik
journalctl -u traefik -f

A healthy first run looks like this:

dns01: preparing to solve the challenge. domain=*.home.example.com
dns01: waiting for record propagation
The server validated our request.
Validations succeeded; requesting certificates.
Server responded with a certificate. domains=*.home.example.com

Mine took about 12 seconds. Check the issuer from another machine:

echo | openssl s_client -connect kimai.home.example.com:443 -servername kimai.home.example.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject

With staging, the issuer contains (STAGING) and browsers warn. Once every service loads, switch to production:

sed -i '/STAGING/d; /acme-staging/d' /etc/traefik/traefik.yml
: > /etc/traefik/acme/acme.json      # clear the staging cert, keep permissions
systemctl restart traefik

Browsers now show a padlock issued by Let’s Encrypt.


Step 8: Survive ISP outages with a local Pi-hole wildcard

With only the public Cloudflare record, an ISP outage would break my hostnames, even though Traefik and every service are on my LAN. Since I use Kimai daily, I added a local wildcard to Pi-hole.

The Local DNS Records page doesn’t support wildcards, and in current Pi-hole v6 the misc.dnsmasq_lines setting can’t be set from the web UI:

This config option can only be set in pihole.toml, through an environment variable or using pihole-FTL –config, not via the API

So I set it from the Pi-hole host’s shell:

sudo pihole-FTL --config misc.dnsmasq_lines                 # check it's empty: []
sudo pihole-FTL --config misc.dnsmasq_lines '["address=/home.example.com/192.168.10.20"]'
sudo systemctl restart pihole-FTL

(For Pi-hole in Docker, use docker exec, and add FTLCONF_misc_dnsmasq_lines to the compose environment so it persists.)

Verify:

dig +short anything.home.example.com @192.168.10.53    # → 192.168.10.20, answered locally

Step 9: Remote access via Twingate

In the Twingate admin console, under Network → home Remote Network → Add Resource:

  • Label: Homelab (Traefik)
  • Address: *.home.example.com
  • TCP: 443, 80 · UDP: blocked · ICMP: allow
  • Access: add my group (the resource uses Manual Access, so nobody can reach it until assigned)

Tested from my phone with Wi-Fi off: https://kimai.home.example.com loads with a valid padlock. ✅


Step 10: Keeping Traefik updated

A small script checks for a newer v3 release, verifies the checksum, swaps the binary and restarts. It refuses to jump to a new major version without me reading the release notes first.

/usr/local/sbin/update-traefik:

#!/usr/bin/env bash
set -euo pipefail
CUR="v$(traefik version | awk '/^Version:/ {print $2}')"
VER=$(curl -s https://api.github.com/repos/traefik/traefik/releases/latest | grep -Po '"tag_name": "\K[^"]+')
[[ "$CUR" == "$VER" ]] && { echo "Already on $VER"; exit 0; }
[[ "$VER" == v3.* ]] || { echo "Latest is $VER (new major version) - read the release notes first"; exit 1; }
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT; cd "$TMP"
BASE="https://github.com/traefik/traefik/releases/download/${VER}"
curl -sLO "$BASE/traefik_${VER}_linux_amd64.tar.gz"
curl -sLO "$BASE/traefik_${VER}_checksums.txt"
grep "linux_amd64.tar.gz" "traefik_${VER}_checksums.txt" | sha256sum -c -
tar -xzf "traefik_${VER}_linux_amd64.tar.gz" traefik
install -m 755 traefik /usr/local/bin/traefik
systemctl restart traefik
echo "Updated $CUR -> $VER"
chmod 700 /usr/local/sbin/update-traefik
update-traefik

Adding a new service later

  1. Add a router and a service to /etc/traefik/dynamic/services.yml (Traefik reloads automatically).
  2. If the backend uses a self-signed cert, add serversTransport: selfsigned.
  3. If the app cares about proxies (like Home Assistant), trust 192.168.10.20.
  4. Nothing to do for DNS, certificates or Twingate: the wildcards cover it.

Lessons learned

  • Asus AiMesh + DHCP reservations: assigning a new IP to a MAC address has twice taken my whole network down (red LEDs on both units). Recovery is to power-cycle the main router, wait for white, then the node, not a factory reset. Accepting an already-assigned IP as the reservation avoids the problem. Keep a config backup.
  • Clean up after troubleshooting. At one point my Mac had no internet only because it was still on a manual IP I’d set while diagnosing. sudo ipconfig set en8 DHCP fixed it.
  • Docker inside LXC is fragile across host and runtime updates. For a single-binary app using only the file provider, native is simpler and sturdier.
  • Negative DNS caching can make a correct record look broken. Test with a fresh name.
  • The registrar’s dashboard has two “nameserver” places. Delegation lives on the Domain tab, not under Advanced DNS.
  • Know your topology. I’d been describing my services as being on a pfSense VLAN; they’re actually on the Asus LAN. Checking pfSense’s interface status cleared that up and simplified the plan.

Useful troubleshooting commands

# Traefik
systemctl status traefik
journalctl -u traefik -f
journalctl -u traefik --since "10 min ago" | grep -iE "err|warn"

# DNS
dig +short kimai.home.example.com                  # via normal path (Asus → Pi-hole)
dig +short kimai.home.example.com @192.168.10.53     # Pi-hole directly
dig +short kimai.home.example.com @1.1.1.1         # public record

# Certificate
echo | openssl s_client -connect kimai.home.example.com:443 -servername kimai.home.example.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates

# Backend reachability (from the Traefik LXC)
curl -sI  http://<backend-ip>:<port>/ | head -n 1
curl -skI https://<backend-ip>:<port>/ | head -n 1

To do

  • Upgrade Proxmox VE 8.x → 9.x (8 is end of life). Back up guests and /etc/pve, update to the latest 8.4, run pve8to9 --full, then upgrade.
  • Add more services behind Traefik.
  • Consider a second Pi-hole on separate hardware (Raspberry Pi) as DNS 2, for real redundancy and no ad-blocking leaks.
  • Firewall each service to accept traffic only from Traefik, making it the single front door.
  • Retire the old IP-based Twingate resources.
  • Investigate why opening https://pihole.home.example.com from 1Password gives Bad Gateway while typing it in the browser works.
  • Set homelab 1Password items to “Only fill on this exact host”, since all hostnames share example.com.