Traefik Gopher
Copyright of this image belongs to its respective owner(s).

How-to: setting up secure connections using Traefik and a custom root certificate

  • how-to
  • Traefik
  • reverse-proxy
  • step-ca
  • TLS
  • certificate
  • root ca
  • ACME
  • Docker
  • TrueNAS

In this tutorial I'll show you how to set up Traefik as a reverse-proxy, create a root certificate using step and use this with step-ca as an ACME cert resolver to enable secure connections for (Docker) services exposed via Traefik.

Requirements

  • some basic Linux knowlegde
  • basic Docker compose (or similar) knowledge
  • a host machine with a static IP address (recommended) running Docker (or similar)
  • an internal (wildcard) domain name, pointing to the host machine's IP address

The internal domain name can be pretty much anything (eg. example.lan). To point it to an IP address, you need to create a DNS record for that domain on your internal network's DNS server (eg. PiHole) or router.

1. Introduction

Using a reverse-proxy to expose your (Docker) services seems pretty obvious to me. So I won't explain why you would want to do this. Using your own private/internal certificate authority (CA) however is less common. Why would you bother setting one up? Well, at first it gives you the flexibility to generate certificates based on your own root certificate, for any internal domain/device you'd like. For example, this allows for generating TLS certificates for IoT devices or within your local dev environment. And it is able to do so without sending a request to an external service on the internet (eg. Let's Encrypt). You also do not need a public domain name for all this to work. A downside is, you'll need to add your public root certificate to all your devices before they trust any generated certificate that's based on it.

I'll be using a TrueNAS Scale server as my host machine. As of version 24.10, the TrueNAS Apps feature backend uses Docker (compose). TrueNAS "Apps" are actually just Docker containers which are started from a template similar to a compose.yaml file.

I'm also using a Unifi router with Unifi Controller in which I've added a DNS Policy pointing *.svc.lan to the static IP address of my TrueNAS server. This ensures all traffic to this domain from within my network is routed to my server.

If you do not care for any explanation or just want to see the end result, headover to the repository on GitHub.

2. Set up Traefik

We'll start with Traefik by creating a separated Docker network called traefik-public, which we'll add to each service that we want to expose with Traefik.

SSH into your host machine and add a new network:

docker network create --attachable traefik-public

You might have to use sudo if your user is not a member of the docker user group.

I like to keep the "static" files (like compose.yaml and certain configuration files) separated from files that are changed from within the containers (logs, caches, SQLite database etc.) or any sensitive files like secrets. These static files are placed in an app-manifests folder, with sub folders for each service. This allows for a GitOps style approach. At the end of this tutorial the file structure looks a bit like this:

app-manifests
├── step-ca
│	├── certs
│	│	└── root_ca.crt
│	└── compose.yaml
└── traefik
	├── dynamic-config
	│	└── tls.yaml
	├── compose.yaml
	└── traefik.yaml

apps
├── step-ca
│	├── data
│	└── secrets
│		├── root_ca_key
│		└── root_ca_key_password
└── traefik
	└── acme

For this tutorial I'll assume the app-manifests folder is located at /mnt/pool/app-manifests. Of course you may organize your project in a different way, just make sure to keep all paths pointing to the right place.

Copy and paste below YAML content to your app-manifests/traefik/compose.yaml file.

/mnt/pool/app-manifests/traefik/compose.yaml
services:
  traefik:
    image: traefik:v3.6.4 # https://hub.docker.com/_/traefik
    container_name: traefik
    depends_on:
      - socket-proxy
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    user: 1000:1000
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 2048M
    networks:
      - traefik-public # name of the network we created before
      - internal_socket-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      # update the path on the left of : to match your location
      - "/mnt/pool/app-manifests/traefik/traefik.yaml:/etc/traefik/traefik.yml:ro"
    environment:
      TZ: Etc/UTC
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.svc.lan`)" # change hostname
      - "traefik.http.routers.traefik-dashboard.service=api@internal"
      - "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"

  socket-proxy:
    image: ghcr.io/tecnativa/docker-socket-proxy:v0.4.1
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 256M
    networks:
      - internal_socket-proxy
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      SERVICES: 1
      TASKS: 1

networks:
  traefik-public:
    external: true
  internal_socket-proxy:

The above compose.yaml file contains two services; the main Traefik instance and a security-enhanced Docker socket proxy. The socket proxy allows us to limit which Docker API features are accessible, thus limiting the attack surface on the Docker socket. To achieve this, we'll add the socket proxy to an "internal" network which allows Traefik to communicate with it. Other services should not be added to this network and therefore cannot access the socket proxy.

It's time to configure Traefik. We'll use the socket proxy endpoint to access the Docker API. Logs are written to /dev/stdout so they can be inspected using docker logs traefik. Copy and paste below YAML content to your app-manifests/traefik/traefik.yaml:

/mnt/pool/app-manifests/traefik/traefik.yaml
api:
  insecure: true # for now, allow insecure connections
  dashboard: true

providers:
  docker:
    endpoint: "tcp://socket-proxy:2375" # this must match the socket proxy's name and port!
    exposedByDefault: false
    network: traefik-public # name of the network we created before
  file:
    directory: "/etc/traefik/dynamic-config"
    watch: true

entryPoints:
  web:
    asDefault: true
    address: ":80"

log:
  level: DEBUG
  filePath: "/dev/stdout"

accessLog:
  filePath: "/dev/stdout"
  filters:
    statusCodes:
      - "400-499"
      - "500-599"
    retryAttempts: true
    minDuration: "10ms"

global:
  checkNewVersion: true
  sendAnonymousUsage: false

Because Traefik runs on port 80 and 443, it is important that your host machine does not use any of those ports!

It is also possible to run Traefik without access to the Docker socket. Have a look at the provider documentation for additional info and adjust above traefik.yaml according to it.

2.1. Create Traefik app on TrueNAS

As mentioned at the start of this tutorial, I'm using a TrueNAS server as my host machine. Head over to #2.2 if you'd like to use (Docker) compose instead. For this next part I'll assume you already have configured a pool to store your apps on your TrueNAS machine.

To keep things simple, I'll add Traefik as a custom "App" using the TrueNAS webui. Headover to Apps > Discover Apps > Install via YAML. We'll make use of a YAML trick to include our compose.yaml file from our app-manifests folder.

include:
- /mnt/pool/app-manifests/traefik/compose.yaml

Copy/paste above YAML and save it. Your newly created "custom app" should start any moment after the required images are downloaded.

2.2. Run Traefik using compose

Since we've already created a compose.yaml file for Traefik, running the service using Docker compose is a matter of running the following command:

docker compose -f /mnt/pool/app-manifests/traefik/compose.yaml up

The Traefik service should start any moment after the required images are downloaded.

Confirm Traefik is up and running

To test if Traefik runs as expected, open traefik.svc.lan in your browser. You should be greeted by the Traefik dashboard. If, for whatever reason, you get a "404 page not found" error, run docker logs traefik to see what went wrong.

3. Generate a root certificate using step cli

You can skip this part and headover to #4 if you already have a root certificate. Otherwise, continue to generate your own custom root certificate. For this I'll use the step cli which makes it fairly easy to do so + we can use this tool later on to generate additional certificates once the step-ca service is up and running. However, if you do not plan to use this, or don't want to install this tool, feel free to use something else (eg. OpenSSL) to generate a root certificate.

Visit the installation instructions page to install the step cli on your local machine. After that we'll need to create a password for our root certificate and store this in a file. You can create the file yourself and enter a secure password, or run below command to let openssl do it:

openssl rand -hex 32 > ./root_ca_key_password

Now it's time to generate our root certificate and a private key:

step certificate create my-root-ca \
	./root_ca.crt \
	./root_ca_key \
	--profile=root-ca \
	--password-file=./root_ca_key_password

Store your password and the generate private key in a secure location!

4. Set up step-ca

Everything is prepared, time to set up our step-ca ACME server on the host machine. Create the following directories:

drwxrwx--- (770) /mnt/pool/apps/step-ca/data
dr-xr-x--- (550) /mnt/pool/apps/step-ca/secrets

Copy your (generated) public certificate to app-manifests/step-ca/root_ca.crt, your private key to apps/step-ca/secrets/root_ca_key and password file to apps/step-ca/secrets/root_ca_key_password. We'll use these files to start the step-ca service with our existing root certificate thanks to a little PR I submitted.

Keep the private key and password file separated from the "static" config files so you do not accidentally commit it to your (online) repository!

We can now create the compose.yaml file for step-ca:

/mnt/pool/app-manifests/step-ca/compose.yaml
services:
  step-ca:
    image: smallstep/step-ca:0.29.0 # https://hub.docker.com/r/smallstep/step-ca
    container_name: step-ca
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    user: 1000:1000 # same user which owns the apps/step-ca/* dirs
    hostname: step-ca.svc.lan
    networks:
      traefik-public:
        aliases:
          - step-ca.svc.traefik-public.local
    volumes:
      - "/mnt/pool/apps/step-ca/data:/home/step"
    secrets:
      - root_ca.crt
      - root_ca_key
      - root_ca_key_password
    environment:
      DOCKER_STEPCA_INIT_NAME: My intermediate CA
      DOCKER_STEPCA_INIT_DEPLOYMENT_TYPE: standalone
      DOCKER_STEPCA_INIT_DNS_NAMES: step-ca.svc.lan,step-ca.svc.traefik-public.local,step-ca
      DOCKER_STEPCA_INIT_ACME: true
    labels:
      - "traefik.enable=true"
      - "traefik.tcp.routers.step-ca.tls.passthrough=true"
      - "traefik.tcp.routers.step-ca.rule=HostSNI(`step-ca.svc.lan`)"
      - "traefik.tcp.services.step-ca.loadbalancer.server.port=9000"

networks:
  traefik-public:
    external: true

secrets:
  root_ca.crt:
    file: /mnt/pool/app-manifests/step-ca/root_ca.crt
  root_ca_key:
    file: /mnt/pool/apps/step-ca/secrets/root_ca_key
  root_ca_key_password:
    file: /mnt/pool/apps/step-ca/secrets/root_ca_key_password

As you may have noticed, we set both a hostname and alias for the traefik-public networks. The healthcheck call from within the step-ca container uses the first DNS entry set by DOCKER_STEPCA_INIT_DNS_NAMES, prefixed with the port. And because we configure Traefik to send any calls from ports :443 to :9000, we cannot use the reverse-proxy. A quickfix is to set the hostname which resolves to the container itself. The network alias is needed because Traefik fails to reach the step-ca.svc.lan hostname.

Also, the Traefik labels for our step-ca service are a bit different from our Traefik dashboard; we'll configure this service to allow TLS passthrough. This is needed because step-ca generates its own TLS certificate based on the provided root certificate. So in this case, Traefik should not generate a TLS certificate. Thus we have to configure a TCP router, which allows the TLS certificate to passthrough, instead of an HTTP router.

With the compose.yaml file created, its time to start the container. Either add and run the step-ca service as a custom TrueNAS App:

include:
- /mnt/pool/app-manifests/step-ca/compose.yaml

Or use Docker compose:

docker compose -f /mnt/pool/app-manifests/step-ca/compose.yaml up

The container should start and performs its first healthcheck after ~30 seconds.

...
step-ca  | 2025/12/10 14:47:50 Serving HTTPS on :9000 ...
step-ca  | time="2025-12-10T14:48:20Z" level=info method=GET name=ca path=/health protocol=HTTP/2.0status=200 user-agent="Smallstep CLI/0.29.0 (linux/amd64)"

Run below command to check if the container is marked as healthy:

docker ps -f health=healthy
CONTAINER ID   IMAGE                      COMMAND                  CREATED          STATUS                    PORTS     NAMES
b6022391a371   smallstep/step-ca:0.29.0   "/bin/bash /entrypoi…"   49 seconds ago   Up 48 seconds (healthy)             step-ca

4.1. Configure step-ca as a cert resolver for Traefik

Now we have step-ca up and running, we'll need to adjust the Traefik config a bit so it will use our step-ca service as a cert resolver.

Update your app-manifests/traefik/traefik.yaml file to include the following:

/mnt/pool/app-manifests/traefik/traefik.yaml
api:
  insecure: true # for now, allow insecure connections
  dashboard: true

providers:
  docker:
    endpoint: "tcp://socket-proxy:2375" # this must match the socket proxy's name and port!
    exposedByDefault: false
    network: traefik-public # name of the network we created before
  file:
    directory: "/etc/traefik/dynamic-config"
    watch: true

entryPoints:
  web:
    asDefault: true
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    asDefault: true
    address: ":443"
    http:
      tls:
        certResolver: step-ca

certificatesResolvers:
  step-ca:
    acme:
      email: your-email@example.com
      storage: "/etc/traefik/acme/acme.json"
      caCertificates:
        - "/etc/traefik/certs/roots.pem"
      caServer: "https://step-ca.svc.traefik-public.local:9000/acme/acme/directory"
      caServerName: step-ca
      tlsChallenge: true
      httpChallenge:
        entryPoint: web

log:
  level: DEBUG
  filePath: "/dev/stdout"

accessLog:
  filePath: "/dev/stdout"
  filters:
    statusCodes:
      - "400-499"
      - "500-599"
    retryAttempts: true
    minDuration: "10ms"

global:
  checkNewVersion: true
  sendAnonymousUsage: false

Add the following TLS config:

/mnt/pool/app-manifests/traefik/dynamic-config/tls.yaml
tls:
  options:
    default:
      minVersion: VersionTLS12
      cipherSuites:
        - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
        - TLS_AES_128_GCM_SHA256
        - TLS_AES_256_GCM_SHA384
        - TLS_CHACHA20_POLY1305_SHA256
      curvePreferences:
        - CurveP521
        - CurveP384

Finally we need to update our compose.yaml.

/mnt/pool/app-manifests/traefik/compose.yaml
services:
  traefik:
    image: traefik:v3.6.4 # https://hub.docker.com/_/traefik
    container_name: traefik
    depends_on:
      - download-certs
      - socket-proxy
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    user: 1000:1000
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 2048M
    networks:
      - traefik-public # name of the network we created before
      - internal_socket-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      # update the path on the left of : to match your location
      - "/mnt/pool/app-manifests/traefik/traefik.yaml:/etc/traefik/traefik.yml:ro"
      - "/mnt/pool/app-manifests/traefik/dynamic-config:/etc/traefik/dynamic-config:ro"
      - "/mnt/pool/apps/traefik/acme:/etc/traefik/acme:rw"
      - "/mnt/pool/apps/traefik/certs:/etc/traefik/certs:ro"
    environment:
      TZ: Etc/UTC
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.svc.lan`)"
      - "traefik.http.routers.traefik-dashboard.service=api@internal"
      - "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"

  download-certs:
    image: alpine/curl:8.14.1
    security_opt:
      - no-new-privileges:true
    user: 1000:1000
    networks:
      - traefik-public
    volumes:
      - "/mnt/pool/apps/traefik/certs:/etc/certs:rw"
    command: --insecure https://step-ca.svc.traefik-public.local:9000/roots.pem --output /etc/certs/roots.pem

  socket-proxy:
    image: ghcr.io/tecnativa/docker-socket-proxy:v0.4.1
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 256M
    networks:
      - internal_socket-proxy
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      SERVICES: 1
      TASKS: 1

networks:
  traefik-public:
    external: true
  internal_socket-proxy:

That's a lot of configuration. Here's how it works. We've got our step-ca service up and running, with a network alias that's reachable by Traefik. Before Traefik starts, the download-certs service downloads the public root certificates from step-ca to the shared volume /mnt/pool/apps/traefik/certs. Traefik needs these certificates, otherwise it cannot verify the secure connection and will not allow any calls to the respective backend service.

5. Invoking trust using our root certificate

The final step; we need to install our root certificate on each computer, smartphone or other device that wants to securely access the services which we expose via Traefik. We'll do this by adding the generated root.crt file to the trusted certificate store on the relevant device. This can be a hassle if you have to copy the file multiple times. Luckily, the step-ca service has an endpoint which serves our generated root certificate. And since we already have step-ca exposed via Traefik, its fairly easy to make use of this.

5.1. Install on Ubuntu

Letting Ubuntu trust our root certificate is pretty easy. Simply download the certificate and copy it to the /usr/local/share/ca-certificates/ folder. Make sure curl and ca-certificates are installed and use below commands.

curl --insecure -O https://step-ca.svc.lan/roots.pem
sudo cp roots.pem /usr/local/share/ca-certificates/my-root-ca.crt
sudo update-ca-certificates

Remember, at this point the root certificate is not yet installed on this machine so we have to add the --insecure flag to the curl command. Otherwise it'll fail because the secure connection cannot be verified yet.

To verify our root certificate is added, run:

sudo ls /etc/ssl/certs/ | grep my-root-ca

6. Test SSL redirect using whoami

It's time for the final test to see if TLS certificates are generated for any service we'd like to expose. A simple way to do this is using the traefik/whoami container image, by adding and running the following compose file:

/mnt/pool/app-manifests/whoami/compose.yaml
services:
  whoami:
    image: traefik/whoami:latest # https://hub.docker.com/r/traefik/whoami
    restart: unless-stopped
    read_only: true
    security_opt:
      - no-new-privileges=true
    user: 1000:1000
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.svc.lan`)"

networks:
  traefik-public:
    external: true

When you visit http://whoami.svc.lan, you should be redirected to the https domain, which is protected using a TLS certificate based on our root certificate.

Hostname: 3620a9487b87
IP: 127.0.0.1
IP: ::1
IP: 172.16.7.3
IP: fdd0:0:0:6::3
IP: fe80::42:acff:fe10:703
RemoteAddr: 172.16.7.10:44536
GET / HTTP/1.1
Host: whoami.svc.lan
Upgrade-Insecure-Requests: 1
X-Forwarded-For: 192.168.1.2
X-Forwarded-Host: whoami.svc.lan
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 7434e97d8d79
X-Real-Ip: 192.168.1.2

Conclusion

That's pretty much it. I hope you found this tutorial useful. There's always room for improvements, so if you have any suggestions or spot an error, please let me know.