Evaggelos Balaskas - System Engineer

The sky above the port was the color of television, tuned to a dead channel

Blog
Posts
Wiki
About
Contact
rss.png twitter linkedin github gitlab profile for ebal on Stack Exchange

Next Page »
  -  
Dec
24
2025
Exposing Homelab Services Securely with Traefik, Tailscale, and Let’s Encrypt
Posted by ebal at 00:03:41 in blog

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:

diagram.png

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:

  1. A domain name (e.g. example.org)
  2. A DNS provider supported by Traefik (LuaDNS in this case)
  3. A public server (VPS, cloud VM) for Traefik
  4. Tailscale installed on:

    • The Traefik host
    • Your homelab / home PC
  5. 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.

immich.png

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 !

  -  

Search

Admin area

  • Login

Categories

  • blog
  • wiki
  • pirsynd
  • midori
  • books
  • archlinux
  • movies
  • xfce
  • code
  • beer
  • planet_ellak
  • planet_Sysadmin
  • microblogging
  • UH572
  • KoboGlo
  • planet_fsfe

Archives

  • 2025
    • December
    • October
    • September
    • April
    • March
    • February
  • 2024
    • November
    • October
    • August
    • April
    • March
  • 2023
    • May
    • April
  • 2022
    • November
    • October
    • August
    • February
  • 2021
    • November
    • July
    • June
    • May
    • April
    • March
    • February
  • 2020
    • December
    • November
    • September
    • August
    • June
    • May
    • April
    • March
    • January
  • 2019
    • December
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2018
    • December
    • November
    • October
    • September
    • August
    • June
    • May
    • April
    • March
    • February
    • January
  • 2017
    • December
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2016
    • December
    • November
    • October
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2015
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • January
  • 2014
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2013
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2012
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2011
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2010
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
  • 2009
    • December
    • November
    • October
    • September
    • August
    • July
    • June
    • May
    • April
    • March
    • February
    • January
Ευάγγελος.Μπαλάσκας.gr

License GNU FDL 1.3 - CC BY-SA 3.0