Prologue – Why do this at all?
Running services at home is fun. Running them securely and reliably is where things get interesting.
In my homelab, I run many applications, like Immich on a legacy PC, behind a residential ISP connection, dynamic IPs, and without opening ports on my router. This setup provides my test lab and a way to play and learn without the use of any cloud. At the same time, I want to use some of my internal services from the internet as securely as I can.
This post describes how to achieve exactly that using:
- Tailscale as a secure private network between hosts
- DNS-01 Let’s Encrypt challenges for automated TLS
- A remote homelab service (Immich) reachable only over Tailscale
Important: The DNS record must exist before Traefik requests certificates.
High-level architecture
Before diving into configs, let’s clarify the flow:

Key points:
- DNS entry (eg. immich.example.org) exists before Traefik starts, enabling ACME issuance
- Traefik as an internet-facing reverse proxy - Only Traefik is exposed to the internet
- Immich listens on a private Tailscale IP (100.x.x.x)
- Valid TLS certificates from Let’s Encrypt - TLS is terminated at Traefik
- No inbound firewall rules on my home network - No port forwarding on the home router
- Minimal attack surface
- Clean separation between edge and internal services
Why not expose Immich directly?
Opening ports on a home router comes with downsides:
- Public IP changes
- Consumer-grade firewalling
- Direct exposure of application vulnerabilities
- Harder TLS automation
This setup avoids all of that.
Why Tailscale?
Tailscale gives you:
- WireGuard-based encryption by default
- Stable private IPs
- Mutual authentication
- No inbound NAT rules
- Fine-grained ACLs (optional, but recommended)
Even if Traefik were compromised, the blast radius is limited to what it can access over Tailscale.
Why DNS-01 instead of HTTP-01?
DNS-01 lets Traefik:
- Obtain certificates without the backend being reachable
- Issue certs before the service is live
- Avoid exposing port 80 on internal services
This is especially useful when the backend is private or remote.
Prerequisites
Before starting, make sure you have:
- A domain name (e.g.
example.org) - A DNS provider supported by Traefik (LuaDNS in this case)
- A public server (VPS, cloud VM) for Traefik
-
Tailscale installed on:
- The Traefik host
- Your homelab / home PC
-
A DNS record:
immich.example.org → <Traefik public IP>
Important: The DNS record must exist before Traefik requests certificates.
Traefik setup (edge host)
Docker Compose
Traefik runs as a standalone service on the edge host:
---
services:
traefik:
image: traefik:v3.6
container_name: traefik
hostname: traefik
env_file:
- ./.env
environment:
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${LUADNS_API_USERNAME}
restart: unless-stopped
ports:
- 8080:8080 # Dashboard (secured, no insecure mode)
- 80:80 # HTTP
- 443:443 # HTTPS
volumes:
- ./certs:/certs # For static certificates
- ./etc_traefik:/etc/traefik # Traefik configuration files
- /var/run/docker.sock:/var/run/docker.sock:ro # So that Traefik can listen to the Docker events
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 30s
retries: 3
timeout: 10s
start_period: 10s
Static Traefik configuration (traefik.yml)
This file defines entrypoints, providers, logging, and ACME:
ping: {}
api:
dashboard: true
insecure: false
log:
filePath: /etc/traefik/traefik.log
level: INFO
entryPoints:
web:
address: ":80"
reusePort: true
websecure:
address: ":443"
reusePort: true
providers:
docker:
exposedByDefault: false
file:
directory: /etc/traefik/dynamic/
watch: true
We explicitly disable auto-exposure of Docker containers and rely on file-based dynamic config to have more control on which docker services we want traefik to “see”.
Let’s Encrypt via DNS-01 (LuaDNS)
certificatesResolvers:
letsencrypt:
acme:
email: ""
storage: "/certs/acme.json"
caServer: https://acme-v02.api.letsencrypt.org/directory
dnsChallenge:
provider: luadns
delayBeforeCheck: 0
resolvers:
- "8.8.8.8:53"
- "1.1.1.1:53"
Why this matters:
- Certificates can be issued even if Immich is offline
- No need for port 80 reachability
- Works cleanly with private backends
Dynamic routing to Immich over Tailscale
This is where the magic happens.
Dynamic config (dynamic/immich.yml)
http:
routers:
immich:
rule: 'Host(`immich.example.org`)'
entryPoints: ["websecure"]
service: "immich"
tls:
certResolver: letsencrypt
services:
immich:
loadBalancer:
servers:
- url: "http://100.80.90.101:2283"
passHostHeader: true
Explanation:
Host()rule matches your public domain- TLS is terminated at Traefik
- Backend URL is a Tailscale IP
- No exposure of Immich to the public internet
Homelab: Immich setup
On the home PC, Immich runs normally, bound to a local port:
ports:
- '2283:2283'
Make sure to use the docker-compose.yml of the current release:
This port does not need to be:
- Exposed to the internet
- Forwarded on your router
- Secured with TLS
It only needs to be reachable from the Traefik host via Tailscale.
Verifying the setup
Visit: https://immich.example.org
You should get a valid Let’s Encrypt certificate and a working Immich UI.
Hardening ideas (recommended)
Once this works, consider:
- Tailscale ACLs limiting Traefik → Immich access
- Middleware for:
- Security headers
- Rate limiting
- IP allowlists
- Traefik dashboard behind auth
- Separate internal / external entrypoints
That's it !
