Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Containers Difficulty: Intermediate

Setting up Traefik: reverse proxy with automatic HTTPS

Traefik as a reverse proxy in front of your containers, with automatic Let's Encrypt certificates: every app gets a domain and HTTPS via a few labels.

· 13 min read ·Duration: approx. 60 minutes
Table of contents

This is the most important building block of the Serverküche. A reverse proxy takes in all requests on ports 80 and 443 and distributes them to the right container based on the domain – and Traefik fetches the HTTPS certificates fully automatically from Let’s Encrypt. From here on, every further app gets its domain and its TLS with a few lines of labels, without you ever touching a certificate by hand again.

What are we building?

By the end, Traefik v3 runs as the central entry point on your server. It listens on ports 80/443, detects new containers automatically via Docker labels, redirects HTTP to HTTPS automatically, and obtains a valid Let’s Encrypt certificate for every domain. As the first app, we hang whoami behind the proxy – a tiny test service that shows routing and TLS are working. A secured dashboard comes on top.

The pattern from this tutorial – a shared proxy network plus a few labels – repeats afterwards in every app recipe.

A request always passes through the same four stations in Traefik – this vocabulary helps you debug:

  1. Entrypoint – the port on which the request arrives (web = 80, websecure = 443).
  2. Router – decides based on a rule (usually Host(...)) whether this request belongs to an app.
  3. Middleware (optional) – modifies the request along the way (e.g. HTTPS redirect, basic auth, security headers).
  4. Service – the container that ultimately responds.

Debugging mnemonic: “Entrypoint → Router → Middleware → Service”. If a request ends up nowhere, it’s almost always the router (wrong domain) or the network (service not reachable) at fault.

Prerequisites

No resolving domain, no certificate

Let’s Encrypt only issues certificates for domains it can reach. Check beforehand with dig +short YOUR_DOMAIN that your server IP comes back. If the record still points nowhere, certificate issuance fails – that’s the most common Traefik error of all.

Step by step

Step 1: Create the shared proxy network

Traefik and all apps must share a Docker network so Traefik can reach the containers. We create it once and explicitly, so later stacks can simply dock onto it:

Terminal
docker network create proxy

Check:

Terminal
docker network ls | grep proxy
Ausgabe
c442da869c47   proxy     bridge    local

This network is independent of the individual Compose projects – that’s why we later include it as external.

Step 2: Create the Traefik project

Create a dedicated folder for Traefik and, inside it, the file where the certificates are stored:

Terminal
mkdir -p ~/traefik && cd ~/traefik
touch acme.json
chmod 600 acme.json

acme.json needs 600

Without chmod 600 acme.json, Traefik skips the Let’s Encrypt resolver: the container does start, but issues no valid certificate – you land on Traefik’s self-signed emergency certificate. The log then reads permissions 644 for /acme.json are too open, please use 600. The file contains your private keys – only the owner may read it.

Step 3: The Traefik compose.yaml

Now the central configuration. It’s long, but every line has a purpose – the explanation follows right below:

YAML
services:
  traefik:
    image: traefik:v3.7
    command:
      # Dashboard (secured in step 7)
      - "--api.dashboard=true"
      # Docker as source; only containers with traefik.enable=true
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      # Entrypoints: 80 (HTTP) and 443 (HTTPS)
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      # Redirect everything from HTTP to HTTPS automatically
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      # Let's Encrypt resolver named "le" via HTTP challenge
      - "--certificatesresolvers.le.acme.email=YOUR_EMAIL"
      - "--certificatesresolvers.le.acme.storage=/acme.json"
      - "--certificatesresolvers.le.acme.httpchallenge=true"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./acme.json:/acme.json
    networks:
      - proxy
    restart: unless-stopped

networks:
  proxy:
    external: true

The most important blocks:

  • providers.docker + exposedbydefault=false: Traefik watches the Docker socket, but only containers that explicitly carry traefik.enable=true. No service is accidentally made public.
  • providers.docker.network=proxy: tells Traefik which network it uses to reach the containers – important when containers are attached to several networks.
  • entrypoints web/websecure: ports 80 and 443. The two redirections lines send every HTTP call automatically to HTTPS.
  • certificatesresolvers.le: the Let’s Encrypt resolver. Via the HTTP challenge, Traefik proves to Let’s Encrypt that the domain points to this server and stores the certificate in acme.json. For this, port 80 must stay reachable from outside – even if your app only runs over HTTPS, because the challenge comes over HTTP. Let’s Encrypt uses the acme.email solely for warnings about expiring certificates; enter a real address.
  • The Docker socket is mounted read-only (:ro) – Traefik must read it, but not write to it.

Test with the staging server first

Let’s Encrypt has strict rate limits for production certificates. While you’re still building, add --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory as a test. This delivers test certificates (shown as insecure in the browser) without a limit. Once everything works, remove the line, empty acme.json (> acme.json) and restart Traefik – then the real certificate comes.

Start Traefik:

Terminal
docker compose up -d
docker compose logs -f traefik

The logs must show no ERR about ACME or the provider. Ctrl+C only ends the following, not the container.

An empty log is a good sign

Traefik v3 writes only errors at the default log level. So an empty log output means: everything is running. If you want to see more during setup (every detected router, every ACME request), add --log.level=INFO to the command block and restart.

Step 4: The first app behind Traefik (whoami)

whoami is a tiny service that returns the received request – perfect for testing. Own folder, own compose.yaml:

Terminal
mkdir -p ~/whoami && cd ~/whoami
YAML
services:
  whoami:
    image: traefik/whoami:v1.11
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.YOUR_DOMAIN`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=le"
    networks:
      - proxy
    restart: unless-stopped

networks:
  proxy:
    external: true

These are the four labels you’ll need again and again from now on:

  • traefik.enable=true – only then does Traefik touch the container.
  • ...routers.whoami.rule=Host(...) – at which domain this container responds. whoami is a freely chosen router name (unique per container).
  • ...entrypoints=websecure – reachable over HTTPS (443).
  • ...tls.certresolver=le – fetch the certificate via the resolver le defined in step 3.

Important: the service has no ports: – it’s only reachable via Traefik, not directly from outside. And it’s on the proxy network, otherwise Traefik won’t find it.

Create the DNS record whoami.YOUR_DOMAIN beforehand (A/AAAA to the server IP, as in the DNS tutorial), then:

Terminal
docker compose up -d

Open https://whoami.YOUR_DOMAIN in the browser. On the first call, certificate issuance takes a few seconds; after that you see a valid padlock icon and a text output like:

Ausgabe
Hostname: bfa4b8dee3d0
IP: 127.0.0.1
IP: 172.19.0.3
RemoteAddr: 172.19.0.2:49734
GET / HTTP/1.1
Host: whoami.YOUR_DOMAIN

The Host: line confirms that Traefik routed correctly to this container based on the domain. Exactly this behavior – a request for whoami.YOUR_DOMAIN lands at the whoami container, an unknown domain gets a 404 – is the heart of the reverse proxy.

Check which CA issued the certificate. This separates “HTTPS is running” from “I only see Traefik’s emergency certificate”:

Terminal
echo | openssl s_client -connect whoami.YOUR_DOMAIN:443 -servername whoami.YOUR_DOMAIN 2>/dev/null | openssl x509 -noout -issuer

As long as you use the staging server (as recommended in step 3), a test issuer appears there – the browser still shows the certificate as insecure:

Ausgabe
issuer=C=US, O=Let's Encrypt, CN=(STAGING) Ersatz Emmer YR2

If TRAEFIK DEFAULT CERT appears here, the resolver fetched no certificate – then go to troubleshooting below. If a Let’s Encrypt issuer is there, the whole chain works.

Step 5: Switch to the real certificate

Once staging runs cleanly, you fetch the real certificate that’s valid in the browser. Remove the caserver line from the traefik service (step 3), empty the staging certificates and restart Traefik:

Terminal
> acme.json                 # discards the staging certificates (chmod 600 stays)
docker compose up -d

On the next call, Traefik fetches a fresh production certificate. In the issuer line from above, the (STAGING) then disappears, and the browser shows a valid padlock.

Staging first, then production

Let’s Encrypt has hard rate limits on production certificates (a few per domain per week). Only switch to production once routing and challenge demonstrably work with staging – otherwise you lock yourself out of the domain for hours.

Step 6: The HTTP-to-HTTPS redirect

You already enabled this globally in step 3 (the two redirections lines). Test:

Terminal
curl -sI http://whoami.YOUR_DOMAIN | grep -iE 'HTTP/|location'
Ausgabe
HTTP/1.1 308 Permanent Redirect
location: https://whoami.YOUR_DOMAIN/

So every unencrypted call is automatically redirected to HTTPS – you no longer have to think about it in any app.

Step 7: Secure the dashboard

Traefik comes with a dashboard that shows which routers and services are active. Never put it unprotected on the internet. We secure it with basic auth and hang it on a dedicated subdomain. First create a user:

Terminal
sudo apt install -y apache2-utils
htpasswd -nbB admin YOUR_PASSWORD

The output (admin:$2y$05$...) goes into the labels. In the compose.yaml, double every $ ($$), otherwise Compose interprets it as a variable. Add to the traefik service:

YAML
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.YOUR_DOMAIN`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=le"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$..."

After docker compose up -d you reach the dashboard at https://traefik.YOUR_DOMAIN – after a password prompt.

In the dashboard you see, under HTTP → Routers, every detected router (with its Host(...) rule), under Services the containers behind them, and under Middlewares your building blocks like dashboard-auth. A router turns green when rule, service and – for websecure – the certificate are correct; red means something is missing (usually network or host rule). That makes the dashboard your first look at “why isn’t my app responding?”.

The Traefik dashboard: entrypoints (web/websecure), detected HTTP routers and services – all green
The Traefik dashboard at a glance

Under HTTP Routers you see each router individually – with its Host(...) rule, the entrypoint, the TLS status (padlock) and the provider docker. This is how you check at a glance whether your labels were detected correctly:

The router list in the Traefik dashboard: per app the host rule, the entrypoint (websecure), TLS and the Docker provider
HTTP routers with host rules, entrypoint and provider

Step 8: Security headers as a reusable middleware

A middleware hooks in between router and service and modifies the request or response. A set of security headers belongs on every public app – defined once, attached everywhere. Define the middleware on any container (common: on Traefik itself) via labels:

YAML
      - "traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true"
      - "traefik.http.middlewares.sec-headers.headers.frameDeny=true"
      - "traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true"
      - "traefik.http.middlewares.sec-headers.headers.browserXssFilter=true"

What the most important ones do:

  • stsSeconds (HSTS) – the browser will address the domain only over HTTPS from now on. One year (31536000) is the usual value.
  • frameDeny – forbids embedding in foreign <iframe>s (clickjacking protection).
  • contentTypeNosniff – the browser doesn’t guess the content type but takes the one delivered – rules out a whole class of attacks.

Attach it to an app via a label (adjust the router name):

YAML
      - "traefik.http.routers.whoami.middlewares=sec-headers"

Multiple middlewares are given comma-separated (sec-headers,dashboard-auth) and are run in that order. This is how you gradually build a toolbox (auth, rate limiting, IP whitelist) that every app can reuse.

If a header set should apply to all apps, you don’t attach the middleware to each router individually, but globally to the entrypoint – one line in Traefik’s command block:

YAML
      - "--entrypoints.websecure.http.middlewares=sec-headers@docker"

The suffix @docker tells Traefik the middleware comes from the Docker provider (where you defined it via label).

Tip

Only arm HSTS with stsSeconds once HTTPS runs reliably and permanently. The browser remembers the setting stubbornly – a broken certificate would then be hard to work around for the full duration.

Step 9: The recipe for every further app

From now on, every app is the same pattern – you never have to touch Traefik again. A new application gets its own folder with a compose.yaml, is on the proxy network, and carries exactly these labels (adjust router name and domain; for a port ≠ 80 add the loadbalancer label as well):

YAML
services:
  myapp:
    image: YOUR_IMAGE:TAG
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=Host(`app.YOUR_DOMAIN`)"
      - "traefik.http.routers.myapp.entrypoints=websecure"
      - "traefik.http.routers.myapp.tls.certresolver=le"
      # only needed if the app does NOT listen on port 80:
      - "traefik.http.services.myapp.loadbalancer.server.port=YOUR_PORT"
    networks:
      - proxy
    restart: unless-stopped

networks:
  proxy:
    external: true

docker compose up -d, set the DNS record to the server IP, done – domain and HTTPS are created automatically. This is exactly how the first real app (Uptime Kuma) hangs behind the proxy.

When things go wrong

“Certificate invalid” in the browser, or the Traefik log shows ACME errors. The three usual reasons: (1) The DNS record doesn’t point to the server yet – check dig +short YOUR_DOMAIN. (2) Port 80 isn’t reachable from outside (firewall/netcup firewall) – the HTTP challenge needs it. (3) You hit the rate limit of the production CA – switch to the staging server (tip in step 3), test, then go back.

404 page not found when calling the app domain. Traefik doesn’t know the route. Check: Does the container have traefik.enable=true? Is it on the proxy network? Is the domain in the Host(...) rule exactly right (incl. subdomain)? The dashboard (step 7) shows under “HTTP Routers” whether the router was registered.

The browser shows Traefik’s self-signed emergency certificate; the log reads permissions 644 for /acme.json are too open, please use 600. Traefik is running but skipped the ACME resolver – hence no real certificate. Run chmod 600 acme.json (step 2) and restart the container.

No app is routed; the Traefik log repeats client version 1.24 is too old. Minimum supported API version is 1.40. Your Traefik version is too old for your Docker engine – the Docker provider can no longer query the socket. Current Docker (Engine 29, API level ≥ 1.40) needs Traefik ≥ v3.5; that’s why this tutorial uses traefik:v3.7. Older tags like v3.3 no longer work with new Docker – bump the image tag and run docker compose up -d again.

Basic auth on the dashboard is rejected immediately / the router is missing. In the compose.yaml, the $ characters of the hash must be doubled ($$). Check the hash once more outside with htpasswd -nbB.

Gateway Timeout or Traefik doesn’t reach the container. Usually the app is on the wrong network, or Traefik doesn’t know which one is meant. providers.docker.network=proxy in Traefik and networks: [proxy] on the app must match.

502 Bad Gateway, even though the container is running. Traefik reaches the container but hits the wrong port. If the app doesn’t listen on 80, it needs the label traefik.http.services.<name>.loadbalancer.server.port=<real-port>. This exact case meets you with the first app in the next tutorial (Uptime Kuma on 3001).

Maintenance & backups

  • You must back up acme.json and all compose.yaml files. With those, Traefik is restored within minutes after a crash – the certificates don’t need to be reissued (which also spares the rate limit). We build an encrypted off-site backup of these files in the Restic tutorial.
  • Certificates renew automatically. Let’s Encrypt certificates expire after 90 days; Traefik renews them in good time on its own – no cron job needed. You can check the expiry date any time by appending -dates instead of -issuer to the openssl command from step 4 (shows notBefore/notAfter).
  • Maintain the Traefik version. The fixed tag (traefik:v3.7) means you apply updates deliberately. Before jumping to a new minor/major version, read the release notes – Traefik changed the label syntax between v2 and v3, for example.
  • Keep an eye on the dashboard. A quick login shows whether all routers are “green” – the fastest check of whether everything holds after a deploy.

From now on the path is the same for every app: container onto the proxy network, four labels on it, set the DNS record – and a publicly reachable service with HTTPS is ready. As the first real app, in the next recipe we hang Uptime Kuma behind Traefik and use it to monitor all subsequent services.

Last updated: Jul 19, 2026

What's next?

You might also like

Installing Docker on Debian
Containers Beginner

Installing Docker on Debian

Install Docker Engine and Docker Compose cleanly from the official repository – the foundation for most self-hosting …

· 3 min read