Lock Down Your Homelab With Free SSL — No Port 80 Required #
That red “Not Secure” badge next to your homelab dashboard URL isn’t just ugly — it’s a real risk. Credentials submitted over plain HTTP travel unencrypted, and any device on your LAN can intercept them. But the standard fix has its own problem: Let’s Encrypt’s HTTP-01 challenge expects Port 80 open to the internet, and most residential ISPs block it outright. Even if yours doesn’t, punching that hole in your firewall is a trade-off you shouldn’t have to make.
There’s a better path. The DNS-01 challenge proves domain ownership through a TXT record in DNS — no inbound ports, no HTTP exposure. Pair it with Traefik as your reverse proxy and Cloudflare as your DNS provider, and you get automatic certificate issuance and renewal with zero manual Certbot maintenance.
Here’s how to set it up from scratch.
What You’ll Need #
| Requirement | Why |
|---|---|
| A server running Docker & Docker Compose | Hosts Traefik and your apps |
A personal domain (e.g. example.com) on Cloudflare |
DNS-01 needs Cloudflare’s API to create TXT records |
| A Cloudflare API Token with Zone → DNS → Edit permission | Traefik uses this to automate the challenge |
| (Optional) Pi-hole or local DNS resolver | Resolves your domain to local IPs so you skip public round-trips |
How DNS-01 Beats HTTP-01 for Homelabs #
Standard Let’s Encrypt flow (HTTP-01):
- Let’s Encrypt tells you to serve a token at
http://yourdomain/.well-known/acme-challenge/... - Their CA connects to your public IP on Port 80 to verify it.
- Certificate issued.
Three dealbreakers for homelabs:
- ISP blocks Port 80 — most residential providers do.
- Port forwarding exposes a service port to the entire internet.
- CGNAT — carrier-grade NAT makes your home unreachable anyway.
DNS-01 flow:
- Let’s Encrypt tells you to publish a TXT record at
_acme-challenge.yourdomain. - Their CA reads the record from DNS — no connection to your network.
- Certificate issued.
No ports. No inbound traffic. Works behind CGNAT. Works behind double NAT. Works everywhere.
Step 1 — Create the ACME Storage File #
Traefik stores Let’s Encrypt private keys in acme.json. It must exist before launch and be locked down:
mkdir -p /opt/traefik
touch /opt/traefik/acme.json
chmod 600 /opt/traefik/acme.jsonThe 600 permission restricts read/write to the file owner only. Your SSL private keys live here — treat it like a secret.
Step 2 — Write the Traefik Docker Compose #
Create /opt/traefik/docker-compose.yml:
version: "3.8"
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
ports:
- "80:80" # HTTP entrypoint (internal redirect only)
- "443:443" # HTTPS entrypoint
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme.json:/acme.json
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"Key lines explained:
| Line | Purpose |
|---|---|
docker.sock:ro |
Lets Traefik discover containers (read-only — safe) |
CF_DNS_API_TOKEN |
Cloudflare API token for DNS-01 TXT record automation |
dnschallenge=true |
Selects DNS-01 instead of HTTP-01 or TLS-ALPN-01 |
provider=cloudflare |
Tells ACME which DNS plugin to use |
storage=/acme.json |
Persistent cert/key store across restarts |
web → websecure redirect |
Forces all HTTP to HTTPS automatically |
Put your token in a .env file next to the compose file:
echo 'CF_DNS_API_TOKEN=your_cloudflare_api_token_here' > /opt/traefik/.envLaunch Traefik:
docker compose up -dStep 3 — Attach SSL to Your Apps #
Traefik reads Docker labels to know which containers need TLS. Add these four labels to any service you want secured:
services:
homer:
image: b4bz/homer:latest
container_name: homer
labels:
- "traefik.enable=true"
- "traefik.http.routers.homer.rule=Host(`dash.example.com`)"
- "traefik.http.routers.homer.entrypoints=websecure"
- "traefik.http.routers.homer.tls.certresolver=letsencrypt"
networks:
- proxy
networks:
proxy:
external: trueLabel breakdown:
traefik.enable=true— opts this container into Traefik routing.Host(...)— matches requests fordash.example.com.entrypoints=websecure— listens on the HTTPS entrypoint (Port 443).tls.certresolver=letsencrypt— triggers certificate issuance via theletsencryptresolver.
The first request to https://dash.example.com will pause briefly while Let’s Encrypt issues the certificate. Subsequent visits are instant. Renewals happen automatically ~30 days before expiry — no cron, no manual certbot renew.
Every app you add only needs these four labels. That’s the power of centralized cert management with Traefik.
Step 4 — (Optional) Local DNS Resolution #
Right now dash.example.com resolves to your Cloudflare public IP, which may point nowhere useful if you’re only accessing services locally. Two options:
Option A — Pi-hole Local DNS #
Add a Local DNS Record in Pi-hole:
- Domain:
dash.example.com - IP:
192.168.x.x(your server’s LAN IP)
Option B — /etc/hosts Override #
On each client machine:
echo '192.168.x.x dash.example.com' | sudo tee -a /etc/hostsEither way, your browser connects directly on LAN with a valid SSL certificate — best of both worlds.
Troubleshooting #
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | App and Traefik on different Docker networks | Add both to the same proxy network |
| Certificate not issued | Invalid or missing Cloudflare API token | Verify token has Zone → DNS → Edit scope; check Traefik logs with docker logs traefik |
| Challenge timeout | Cloudflare propagation delay (rare) | Wait 1-2 minutes and reload; DNS-01 usually propagates in seconds |
| “Not Secure” after cert issued | Browser caching HTTP response | Hard-refresh (Ctrl+Shift+R) or clear cache |
FAQ #
Does DNS-01 work if my server has no public IP at all? Yes. DNS-01 verification happens entirely at the DNS layer. Let’s Encrypt never connects to your server. This makes it ideal for CGNAT, double-NAT, and air-gapped-egress setups.
Do I need to forward Port 443 either?
No. Port 443 mapping in the compose file is for local access — your browser on the LAN connects to https://dash.example.com:443. If you later expose services publicly (e.g. through a Cloudflare Tunnel), you still don’t need port forwarding.
What happens when the certificate expires? Nothing you do manually. Traefik’s ACME client renews certificates automatically roughly 30 days before expiry. As long as the Traefik container is running, renewal is hands-off.
Can I use a different DNS provider?
Yes — Traefik supports dozens via lego: Route53, DigitalOcean, Google Cloud DNS, Hetzner, and more. Change provider=cloudflare and the corresponding environment variable.
Why not just use Cloudflare’s origin certificate? Cloudflare origin certificates are only trusted by Cloudflare’s proxy. If you access your domain directly (bypassing Cloudflare), the browser rejects the cert. Let’s Encrypt certs are trusted everywhere.
Recap #
| Before | After |
|---|---|
| HTTP dashboards, browser warnings | Valid Let’s Encrypt SSL on every app |
| Port 80 forwarded to the internet | Zero inbound ports |
Manual certbot renew every 60-90 days |
Fully automatic issuance and renewal |
| One cert per app, per process | Centralized — add 4 labels to any container |
Three files: acme.json (chmod 600), docker-compose.yml, .env. Four labels per app. That’s the entire setup.