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
- A Record
- 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