Valid TLS cert for local services with Traefik

I run an Unraid server locally. Remembering ports for each and every service is not going to happen. Remembering names is much easier. Thus a reverse proxy was the way to go. As a starter and for some sane defaults, I followed the Traefik guide by IBRACORP.

A few weeks ago, I had another look into Traefik for my setup. The interesting part: it integrates with Docker. Instead of updating a configuration file and restarting the server, traefik does these things automatically by monitoring labels attached to Docker containers. On the plus side, it also gets TLS certificates automatically via Let’s Encrypt.

Running traefik as a container itself works perfectly fine:

docker run -d --name=traefik --net=bridge
  -e CF_DNS_API_TOKEN=xxx
  -l traefik.http.routers.api.rule=Host(`traefik.domain.com`)
  -l traefik.http.routers.api.entryPoints=https
  -l traefik.http.routers.api.service=api@internal
  -l traefik.enable=true
  -p 443:443/tcp
  -p 80:80/tcp
  -p 8183:8080/tcp
  -v /mnt/user/appdata/traefik:/etc/traefik:rw
  -v /var/run/docker.sock:/var/run/docker.sock:r
  traefik:latest

Simply adding a traefik.enable=true label to a container will make Traefik aware of it. By adding the traefik.http.routers.api.rule=Host(`traefik.domain.com`) and traefik.enable=true label to the Traefik container, I get access to Traefik’s dashboard which may come in handy for monitoring everything.

Traefik tries to determine the port for proxying traffic automatically but on some occasions, a container exposes more than one port. To tell Traefik which port it should use, the following label can be assigned to a container: traefik.http.services.example.loadbalancer.server.port=31338.

In order to request certificates, Traefik has to perform the ACME challenge. To do so, it integrates with several providers to set the required TXT DNS records. Credentials can be passed as environment variables while the rest of the configuration can be found below. This method is required for all wildcard certificates and since I put most of my servers behind Cloudflare anyway, using its API and DNS configuration was the way to go for me. Since I have a local DNS Server, my domain doesn’t even have any A/AAAA records. Nothing from the internet points to it. The domain’s sole purpose is to enable me to use Let’s Encrypt and no self-signed certificates based on a root cert that I then have to install on all my devices.

The following snippet contains the traefik.yml configuration file. It applies some basic settings to every “host”. Settings like default entrypoints for http and https. Due to this config, it will perform an auto-redirect from http to https as well. It also enables the Docker provider and uses Go templates to automatically generate hostnames for every container based on the container’s name unless overwritten by the parameter mentioned above.

Since not all services run on my server via Docker, a file-based config can be used as well. I use it for my 3D printers and for the Unraid server’s dashboard.

Beside that it also configures the provider for the ACME challenge. Due to the entrypoint configuration, it only generates a certificate for domain.com and *.domain.com. So basically what I want, because that way I am not exposing anything via certificate transparency. Even though none of my services are exposed to the internet anyway.

global:
  checkNewVersion: true
  sendAnonymousUsage: false

serversTransport:
  insecureSkipVerify: true

entryPoints:
  http:
    address: :80
    http:
      redirections:
        entryPoint:
          to: https
          scheme: https
  https:
    address: :443
    http:
      tls:
        certResolver: letsencrypt
        domains:
          - main: domain.com
            sans:
              - '*.domain.com'
      middlewares:
        - securityHeaders@file

providers:
  providersThrottleDuration: 2s
  file:
    watch: true
    filename: /etc/traefik/fileConfig.yml
  docker:
    watch: true
    defaultRule: "Host(`{{ lower (trimPrefix `/` .Name )}}.domain.com`)"
    exposedByDefault: false

api:
  dashboard: true
  insecure: true

log:
  level: INFO

certificatesResolvers:
  letsencrypt:
    acme:
      email: [email protected]
      storage: /etc/traefik/acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"

As mentioned above, here’s the fileConfig.yml, which is being loaded as a file provider by Traefik. It’s configured to auto-reload the file in case of changes. As an example I left my Unraid’s server snippet in there. It also provides some sane defaults for TLS and some HTTP security headers. A lot more info about that can be found on Scott Helme’s Blog.

http:
  routers:
    unraid:
      entrypoints:
        - https
      rule: 'Host(`unraid.domain.com`)'
      service: unraid
      middlewares:
        - "securityHeaders"
  services:
    unraid:
      loadBalancer:
        servers:
          - url: http://192.168.0.7:8080/
  middlewares:
    securityHeaders:
      headers:
        customResponseHeaders:
          X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
          X-Forwarded-Proto: "https"
          server: ""
        customRequestHeaders:
          X-Forwarded-Proto: "https"
        sslProxyHeaders:
          X-Forwarded-Proto: "https"
        referrerPolicy: "same-origin"
        hostsProxyHeaders:
          - "X-Forwarded-Host"
        contentTypeNosniff: true
        browserXssFilter: true
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsSeconds: 63072000
        stsPreload: true
tls:
  options:
    default:
      minVersion: VersionTLS12
      cipherSuites:
        - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305

So, to sum things up. My local DNS server points *.domain.com to my unraid server. So all subdomains will resolve to only that server unless configured otherwise like for my 3D printers for example. Traefik is listening on prot 80 and 443 on my server. Thus requests like foobar.domain.com will go to Traefik. If there’s a container named foobar, Traefik will proxy the requests to that container.

This setup even works in Tailscale thanks to Global DNS configured to my local DNS.