Traefik + Tailscale + Authelia + Linode DNS

Locking down your homelab via tailscale is now so simple with this setup.  The best part?  You never have to expose your system to the public internet if you so wish!  We accomplish this by combining Let's Encrypt's DNS Challenge with Linode's API.  To add a dalup of whip cream and a cherry on top, we use Authelia to provide local 2FA authentication!

I plan on going further in-depth to explain what's going on in the near future, but for now this is the gist of it.

Requirements:

This tutorial assumes you have the following requisite knowledge:

  • Linux command line
  • docker-compose
  • yaml syntax

This tutorial assumes you already have the following setup:

  • Linode Account
  • Domain you own in linode dns
    • A Record
      • tailscale.example.com : tailscale ip
    • CNAME Record
      • *.tailscale.example.com : tailscale.example.com
  • Tailscale setup on docker host
  • Argon2 Hash Generator
    • sudo apt install argon2
    • sudo zypper in argon2

Create Docker Networks

Use the following commands to create our docker networks:

traefik-public-bridge:

docker network create -d bridge --subnet 192.168.21.0/24 --gateway 192.168.21.1 -o parent=eth0 traefik-public-bridge

traefik-tailscale-bridge:

docker network create -d bridge --subnet 192.168.23.0/24 --gateway 192.168.21.1 -o parent=tailscale0 traefik-tailscale-bridge

Create Directory Structure

I tend to use /usr/local/docker as a standard place to store my docker-compose and data volumes.  You may choose to do things differently.  If so, adjust these commands accordingly.

Create docker-compose base folder

mkdir /usr/local/docker/traefik

cd into directory

cd /usr/local/docker/traefik

create data directory

mkdir data

create traefik and authelia data directories

mkdir data/traefik data/authelia

create data/authelia/config directory

mkdir data/authelia/config

create users_database.yml

nano data/authelia/

Configure First Authelia User

Generate Password hash:

echo "password" > argon2 YourSaltHere -id -l 32

which outputs:

Type:           Argon2id
Iterations:     3
Memory:         4096 KiB
Parallelism:    1
Hash:           6e941f9fc706f0b07a8f2e46e0c09ad68243d5e04712bc115a50c9564a20d18e
Encoded:        $argon2id$v=19$m=4096,t=3,p=1$c2FsdEl0V2l0aFNhbHQ$bpQfn8cG8LB6jy5G4MCa1oJD1eBHErwRWlDJVkog0Y4
0.029 seconds
Verification ok

edit users_database.yml with your favorite text editor (nano) and use the following format:

users:
    myuser:
        password: $argon2id$v=19$m=4096,t=3,p=1$c2FsdEl0V2l0aFNhbHQ$bpQfn8cG8LB6jy5G4MCa1oJD1eBHErwRWlDJVkog0Y4
        displayname: My User
        email: myuser@example.com
        groups:
            - admins
            - dev
        disabled: false

Configure Authelia

Here is a basic configuration.yml that should get you going:  Be sure to read the comments and change your domains, secrets, and passwords.

To generate a JWT Secret, you can use https://www.grc.com/passwords.htm

###############################################################
#                   Authelia configuration                    #
###############################################################

host: 0.0.0.0
port: 9091
log_level: warn

# This secret can also be set using the env variables AUTHELIA_JWT_SECRET_FILE
# I used this site to generate the secret: https://www.grc.com/passwords.htm

jwt_secret: yqRMLh4sAbQ47mG0jYsv6vzzHJg1CajKve7tB7OSXiYFk9FXE789roSHIn3380y

# https://docs.authelia.com/configuration/miscellaneous.html#default-redirection-url
default_redirection_url: https://auth.example.com

totp:
  issuer: authelia.com
  period: 30
  skew: 1


authentication_backend:
  file:
    path: /config/users_database.yml
    # customize passwords based on https://docs.authelia.com/configuration/authentication/file.html
    password:
      algorithm: argon2id
      iterations: 1
      salt_length: 16
      parallelism: 8
      memory: 512 # blocks this much of the RAM. Tune this.

access_control:
  default_policy: deny

  networks:
    - name: internal
      networks:
      - '192.168.2.0/24' # Your Internal Subnet
      - '192.168.21.0/24'
    - name: tailscale
      networks:
      - 192.168.23.0/24
      - '100.64.0.0/10' # Tailscale Subnet (DO NOT MODIFY)
  rules:
    # Authelia must be bypass
    - domain: auth.example.com
      policy: bypass
    # Traefik Dashboard 2FA
    - domain: "traefik.example.com"
      policy: two_factor
      networks:
      - "internal"
    # Traefik Tailscale Dashboard one_factor (username/password)
    - domain: "traefik.tailscale.example.com"
      policy: one_factor
      networks:
      - "tailscale"
    # Home Assistant No Auth (We can setup authelia in Home Assistant)
    - domain: "hass.example.com"
      policy: bypass
    # Default Policy for any other domains
    - domain: "*.example.com"
      policy: two_factor
    # Let our main domain through without auth
    - domain: "example.com"
      policy: bypass

session:
  name: authelia_session
  # This secret can also be set using the env variables AUTHELIA_SESSION_SECRET_FILE
  # Used a different secret, but the same site as jwt_secret above.
  secret: yqRMLh4sAbQ47mG0jYsv6vzzHJg1CajKve7tB7OSXiYFk9FXE789roSHIn3380y # use docker secret file instead AUTHELIA_SESSION_SECRET_FILE
  expiration: 3600 # 1 hour
  inactivity: 300 # 5 minutes
  domain: example.com # Should match whatever your root protected domain is


regulation:
  max_retries: 3
  find_time: 120
  ban_time: 300

storage:
  encryption_key: yqRMLh4sAbQ47mG0jYsv6vzzHJg1CajKve7tB7OSXiYFk9FXE789roSHIn3380y
# For local storage, uncomment lines below and comment out mysql. https://docs.authelia.com/configuration/storage/sqlite.html
  local:
    path: /config/db.sqlite3
#  mysql:
#  # MySQL allows running multiple authelia instances. Create database and enter details below.
#    host: MYSQL_HOST
#    port: 3306
#    database: authelia
#    username: DBUSERNAME
#    # Password can also be set using a secret: https://docs.authelia.com/configuration/secrets.html
#    # password: use docker secret file instead AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE

notifier:
  smtp:
    host: mail.example.com
    port: 465
    timeout: 10s
    username: no-reply@example.com
    password: YOUR_PASSWORD
    sender: "Authelia <no-reply@example.com>"
    identifier: beardedtek.com
    subject: "[Authelia] {title}"
    startup_check_address: test@authelia.com
    disable_require_tls: false
    disable_starttls: true
    disable_html_emails: false
    tls:
      server_name: mail.example.com
      skip_verify: true

#  # For testing purpose, notifications can be sent in a file. Be sure map the volume in docker-compose.
#  filesystem:
#    filename: /tmp/authelia/notification.txt

Docker-Compose

Fire up nano and paste the following into /usr/local/docker/traefik/docker-compose.yml:

version: "3.3"

networks:
  traefik-public:
    external: true
  traefik-public-bridge:
    external: true
  traefik-tailscale-bridge:
    external: true
services:

  whoami:
    image: traefik/whoami
    networks:
#      - traefik-public
      - traefik-public-bridge
      - traefik-tailscale-bridge
    labels:
    - traefik.enable=true
    - traefik.docker.network=traefik-public
    - traefik.constraint-label=traefik-public
    - traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
    - traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
    - traefik.http.routers.whoami-http.rule=Host(`whoami.example.com`)
    - traefik.http.routers.whoami-http.entrypoints=http
    - traefik.http.routers.whoami-http.middlewares=https-redirect
    - traefik.http.routers.whoami-https.rule=Host(`whoami.example.com`)
    - traefik.http.routers.whoami-https.entrypoints=https
    - traefik.http.routers.whoami-https.tls=true
    - traefik.http.services.whoami.loadbalancer.server.port=80

  traefik:
    container_name: traefik
    image: traefik:v3.0
    restart: unless-stopped
    command:

      # API Setup
      - '--api=true'
      - '--api.dashboard=true'
      - '--api.insecure=false'

      # Global Values
      - '--global.sendAnonymousUsage=false'
      - '--global.checkNewVersion=false'

      # Logging
      - '--log=true'
      - '--log.level=DEBUG'
      - '--log.filepath=/config/traefik.log'
      - '--accesslog'

      # Providers
      # Docker Labels
      - '--providers.docker=true'
      - '--providers.docker.exposedByDefault=false'
      # Dynamic (./data/traefik/dynamic)
      - '--providers.file.directory=/dynamic/'

      # HTTP Entrypoint (redirects to https)
      - '--entryPoints.http=true'
      - '--entryPoints.http.address=192.168.21.201:80/tcp'
      - '--entryPoints.http.http.redirections.entryPoint.to=https'
      - '--entryPoints.http.http.redirections.entryPoint.scheme=https'
      - '--entryPoints.http.forwardedHeaders.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.http.proxyProtocol.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.http.forwardedHeaders.insecure=false'

      - '--entryPoints.https.forwardedHeaders.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.https.proxyProtocol.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.https.forwardedHeaders.insecure=false'
      - '--entryPoints.https.proxyProtocol.insecure=false'
      - '--entryPoints.http.forwardedHeaders.insecure=false'
      - '--entryPoints.http.proxyProtocol.insecure=false'

      # HTTPS Entrypoint
      - '--entryPoints.https=true'
      - '--entryPoints.https.address=192.168.21.201:443/tcp'
      - '--entryPoints.https.forwardedHeaders.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.https.proxyProtocol.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.https.forwardedHeaders.insecure=false'
      - '--entryPoints.https.proxyProtocol.insecure=false'
      - '--entryPoints.http.forwardedHeaders.insecure=false'
      - '--entryPoints.http.proxyProtocol.insecure=false'

      # Tailscale HTTP Entrypoint (redirectrs to tailscale-https)
      - '--entryPoints.tailscale-http=true'
      - '--entryPoints.tailscale-http.address=192.168.23.201:80/tcp'
      - '--entryPoints.tailscale-http.http.redirections.entryPoint.to=tailscale-https'
      - '--entryPoints.tailscale-http.http.redirections.entryPoint.scheme=https'
      - '--entryPoints.tailscale-http.forwardedHeaders.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.tailscale-http.proxyProtocol.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'

      # Tailscale HTTPS Entrypoint
      - '--entryPoints.tailscale-https=true'
      - '--entryPoints.tailscale-https.address=192.168.23.201:443/tcp'
      - '--entryPoints.tailscale-https.forwardedHeaders.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.tailscale-https.proxyProtocol.trustedIPs=10.0.0.0/8,172.16.0.0/16,192.168.0.0/16,fc00::/7'
      - '--entryPoints.tailscale-https.forwardedHeaders.insecure=false'
      - '--entryPoints.tailscale-https.proxyProtocol.insecure=false'
      - '--entryPoints.tailscale-http.forwardedHeaders.insecure=false'
      - '--entryPoints.tailscale-http.proxyProtocol.insecure=false'

      # Let's Encrypt DNS Challenge with Linode
      - "--certificatesresolvers.le.acme.dnschallenge=true"
      - "--certificatesresolvers.le.acme.dnschallenge.provider=linodev4"
      - "--certificatesresolvers.le.acme.email=${LE_EMAIL}"
      - "--certificatesresolvers.le.acme.storage=/certificates/linode.json"

    environment:
      - LINODE_TOKEN=${LINODE_TOKEN}
    networks:
#      - traefik-public
      traefik-public-bridge:
        ipv4_address: 192.168.21.201
      traefik-tailscale-bridge:
        ipv4_address: 192.168.23.201
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data/traefik:/config
      - ./data/traefik/dynamic:/dynamic
      - ./data/traefik/certificates:/certificates
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.traefik.rule=Host(`traefik.example.com`)'
      - 'traefik.http.routers.traefik.entryPoints=https'
      - 'traefik.http.routers.traefik.tls=true'
      - 'traefik.http.routers.traefik.service=api@internal'
      - 'traefik.http.routers.traefik.middlewares=authelia@docker'
      - 'traefik.http.routers.traefik.tls.certresolver=le'

      - 'traefik.http.routers.traefik-tailscale.rule=Host(`traefik.tailscale.example.com`)'
      - 'traefik.http.routers.traefik-tailscale.entryPoints=https'
      - 'traefik.http.routers.traefik-tailscale.tls=true'
      - 'traefik.http.routers.traefik-tailscale.service=api@internal'
      - 'traefik.http.routers.traefik-tailscale.middlewares=authelia@docker'
      - 'traefik.http.routers.traefik-tailscale.tls.certresolver=le'

  authelia:
    container_name: authelia
    image: authelia/authelia
    restart: unless-stopped
    networks:
      - traefik-public
      - traefik-public-bridge
      - traefik-tailscale-bridge
    expose:
      - 9091
    volumes:
      - ./data/authelia/config:/config
      - ./data/authelia/tmp:/tmp/authelia
    environment:
      TZ: "America/Anchorage"
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.authelia.rule=Host(`auth.example.com`)'
      - 'traefik.http.routers.authelia.entryPoints=https'
      - 'traefik.http.routers.authelia.tls=true'
      - 'traefik.http.routers.authelia.tls.certresolver=le'
      - 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/verify?rd=https%3A%2F%2Fauth.example.com%2F'
      - 'traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true'
      - 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email'
      - 'traefik.http.middlewares.authelia-basic.forwardAuth.address=http://authelia:9091/api/verify?auth=basic'
      - 'traefik.http.middlewares.authelia-basic.forwardAuth.trustForwardHeader=true'
      - 'traefik.http.middlewares.authelia-basic.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email'

Configure Access to Services

To limit access to only tailscale, or allow use on the public internet, you simply modify the labels for your container.

For example, If you want to bring up jellyfin ONLY on tailscale, you can now create a docker-compose.yml with the following contents:

version: "3"

services:

  jellyfin:
    image: linuxserver/jellyfin
    container_name: jellyfin
    devices:
      - /dev/dri
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/Anchorage
    volumes:
      - /mnt2/jellyfin-rockon:/config
      - /export/media:/media
      - /export/media/movies:/data/movies
      - /export/media/tv:/data/tv
      - /export/media/music:/data/music
      - /export/media/videos:/data/videos
      - ./cache:/cache
    ports:
      - 8096:8096
    restart: unless-stopped
    networks:
      - traefik-public-bridge
      - traefik-tailscale-bridge
      
labels:
      - 'traefik.http.routers.traefik-tailscale.rule=Host(`jellyfin.tailscale.example.com`)'
      - 'traefik.http.routers.traefik-tailscale.entryPoints=https'
      - 'traefik.http.routers.traefik-tailscale.tls=true'
      - 'traefik.http.routers.traefik-tailscale.service=api@internal'
      - 'traefik.http.routers.traefik-tailscale.middlewares=authelia@docker'
      - 'traefik.http.routers.traefik-tailscale.tls.certresolver=le'

To allow both remote and tailscale access but not auth on tailscale, you can modify the labels to look like this:

    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.api.rule=Host(`jellyfin.example.com`)'
      - 'traefik.http.routers.api.entryPoints=https'
      - 'traefik.http.routers.api.tls=true'
      - 'traefik.http.routers.api.service=api@internal'
      - 'traefik.http.routers.api.middlewares=authelia@docker'
      - 'traefik.http.routers.api.tls.certresolver=le'

      - 'traefik.http.routers.traefik-tailscale.rule=Host(`jellyfin.tailscale.example.com`)'
      - 'traefik.http.routers.traefik-tailscale.entryPoints=https'
      - 'traefik.http.routers.traefik-tailscale.tls=true'
      - 'traefik.http.routers.traefik-tailscale.service=api@internal'
#      - 'traefik.http.routers.traefik-tailscale.middlewares=authelia@docker'
      - 'traefik.http.routers.traefik-tailscale.tls.certresolver=le'

To remove authelia from the chain we comment out or remove this line:

      - 'traefik.http.routers.traefik-tailscale.middlewares=authelia@docker'

Now the path it takes is:

INTERNET -> TAILSCALE -> TRAEFIK -> JELLYFIN

instead of:

INTERNET -> TAILSCALE -> TRAEFIK -> AUTHELIA -> JELLYFIN

Create your .env file

Your .env file houses variables in "bash format" i.e. no spaces before or after the equals sign

LE_EMAIL: email you will receive certificate expiration notices

LINODE_TOKEN: Token you obtain from your linode account to automate DNS Challenges

LE_EMAIL=admin@example.com
LINODE_TOKEN=YOUR_LINODE_API_TOKEN

Bring it all up!

At this point, you should be able to bring it all up and see if it worked!

docker-compose up -d

To see if everything is working, you can check the logs:

docker-compose logs -f

If that does not give enough info, you can get more detail in the individual log files:
Just Authelia:

docker-compose logs -f authelia

Traefik more detailed logs:

tail -f ./data/traefik/traefik.log