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 Lets Encrypt
Posted by ebal at 20:05:43 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 !

Tag(s): traefik, letsencrypt, immch, luadns, tailscale
    Tag: traefik, letsencrypt, immch, luadns, tailscale
Aug
08
2024
Install tailscale to very old linux systems with init script
Posted by ebal at 15:16:04 in blog, planet_ellak, planet_Sysadmin, planet_fsfe

I have many random VPS and VMs across europe in different providers for reasons.

Two of them, are still running rpm based distro from 2011 and yes 13years later, I have not found the time to migrate them! Needless to say these are still my most stable running linux machines that I have, zero problems, ZERO PROBLEMS and are in production and heavily used every day. Let me write this again in bold: ZERO PROBLEMS.

But as time has come, I want to close some public services and use a mesh VPN for ssh. Tailscale entered the conversation and seems it’s binary works in new and old linux machines too.

long story short, I wanted an init script and with the debian package: dpkg, I could use start-stop-daemon.

Here is the init script:

#!/bin/bash

# ebal, Thu, 08 Aug 2024 14:18:11 +0300

### BEGIN INIT INFO
# Provides:          tailscaled
# Required-Start:    $local_fs $network $syslog
# Required-Stop:     $local_fs $network $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: tailscaled daemon
# Description:       tailscaled daemon
### END INIT INFO

. /etc/rc.d/init.d/functions

prog="tailscaled"
DAEMON="/usr/local/bin/tailscaled"
PIDFILE="/var/run/tailscaled.pid"

test -x $DAEMON || exit 0

case "$1" in
  start)
    echo "Starting ${prog} ..."
    start-stop-daemon --start --background --pidfile $PIDFILE --make-pidfile --startas $DAEMON --
    RETVAL=$?
    ;;
  stop)
    echo "Stopping ${prog} ..."
    if [ -f ${PIDFILE} ]; then
        start-stop-daemon --stop --pidfile $PIDFILE --retry 5 --startas ${DAEMON} -- -cleanup
        rm -f ${PIDFILE} > /dev/null 2>&1
    fi
    RETVAL=$?
    ;;
  status)
    start-stop-daemon --status --pidfile ${PIDFILE}
    status $prog
    RETVAL=$?
    ;;
  *)
    echo "Usage: /etc/init.d/tailscaled {start|stop|status}"
    RETVAL=1
    ;;
esac

exit ${RETVAL}

an example:

[root@kvm ~]# /etc/init.d/tailscaled start
Starting tailscaled ...

[root@kvm ~]# /etc/init.d/tailscaled status
tailscaled (pid  29101) is running...

[root@kvm ~]# find /var/ -type f -name "tailscale*pid"
/var/run/tailscaled.pid

[root@kvm ~]# cat /var/run/tailscaled.pid
29101

[root@kvm ~]# ps -e fuwww | grep -i tailscaled
root     29400  0.0  0.0 103320   880 pts/0    S+   16:49   0:00                      _ grep --color -i tailscaled
root     29101  2.0  0.7 1250440 32180 ?       Sl   16:48   0:00 /usr/local/bin/tailscaled

[root@kvm ~]# tailscale up

[root@kvm ~]# tailscale set -ssh

[root@kvm ~]# /etc/init.d/tailscaled stop
Stopping tailscaled ...

[root@kvm ~]# /etc/init.d/tailscaled status
tailscaled is stopped

[root@kvm ~]# /etc/init.d/tailscaled stop
Stopping tailscaled ...

[root@kvm ~]# /etc/init.d/tailscaled start
Starting tailscaled ...

[root@kvm ~]# /etc/init.d/tailscaled start
Starting tailscaled ...
process already running.

[root@kvm ~]# /etc/init.d/tailscaled status
tailscaled (pid  29552) is running...
Tag(s): centos6, tailscale, init
    Tag: centos6, tailscale, init
  -  

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