How-to: setting up secure connections using Traefik and a custom root certificate
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.