How to Set Up Traefik v3 as a Reverse Proxy for Docker on Ubuntu

Written by: Bagus Facsi Aginsa
Published at: 13 May 2026


You have several services running as Docker containers — a web app, an API, maybe a monitoring dashboard. Each one listens on a different port. You want them all accessible on port 443 with proper hostnames and valid SSL certificates. You could reach for Nginx and manually write a server block for each service, but there is a better tool for containerized environments: Traefik.

Traefik watches your Docker containers in real time. When you start a new container with the right labels, Traefik automatically picks it up, creates a routing rule, and optionally issues an SSL certificate — no config file reload, no manual changes. When you stop the container, Traefik removes the route.

In this tutorial, you will deploy Traefik v3 as a Docker container on Ubuntu, configure it to route traffic to other containers by hostname, enable automatic HTTPS with Let’s Encrypt, and secure the Traefik dashboard with basic authentication.


What Is Traefik and How Does It Differ from Nginx?

Both Traefik and Nginx can act as reverse proxies, but they approach configuration from opposite directions.

Nginx is configuration-file-driven. You write a server block, define upstreams, reload the config. It is powerful and battle-tested, but every new service requires a manual file change.

Traefik is infrastructure-aware. It connects to providers — Docker, Kubernetes, Consul — and reads routing configuration directly from them. For Docker, the configuration lives in labels on the container itself, not in a central config file. The service describes its own routing rules.

This makes Traefik particularly well-suited for dynamic environments where containers come and go. If you are already running workloads with Docker Compose and want a clean way to expose them to the internet, Traefik is worth knowing.

Core Concepts

EntryPoint: a port that Traefik listens on. You will define one for HTTP (port 80) and one for HTTPS (port 443).

Router: a rule that matches incoming requests to a service. For example, “if the Host header is app.example.com, send traffic to the webapp service.”

Service: the upstream target — in the Docker provider, this is typically the container and the port it exposes internally.

Middleware: transformations applied between the router and the service. Redirecting HTTP to HTTPS, adding headers, rate limiting, and basic auth are all middlewares.

Provider: the source Traefik watches for configuration. You will use the Docker provider, which reads container labels.

Certificate Resolver: a built-in ACME client that requests and renews TLS certificates from Let’s Encrypt automatically.


Prerequisites

  • Ubuntu 22.04 or 24.04
  • Docker and Docker Compose installed (Getting Started with Docker and Docker Compose on Ubuntu)
  • A domain name with two DNS A records pointing to your server’s public IP:
    • traefik.example.com → your server domain (for the dashboard)
    • whoami.example.com → your server domain (for the test service)
  • Ports 80 and 443 open in your firewall (if you use UFW: sudo ufw allow 80,443/tcp)
  • A non-root user with sudo and Docker access

Replace example.com with your actual domain throughout this tutorial.


Step 1: Create the Project Structure

Organize everything under a single directory so it is easy to manage:

mkdir -p ~/traefik/config
cd ~/traefik

You will end up with two files:

~/traefik/
├── docker-compose.yml
└── config/
    └── traefik.yml

Step 2: Write the Traefik Static Configuration

Traefik’s configuration is split into two layers:

  • Static configuration (traefik.yml): loaded once at startup. Defines entry points, providers, and certificate resolvers.
  • Dynamic configuration: loaded at runtime from providers (Docker labels in your case) and can change without restarting Traefik.

Create the static config file:

nano ~/traefik/config/traefik.yml
# traefik.yml — static configuration

api:
  dashboard: true

log:
  level: INFO

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

providers:
  docker:
    exposedByDefault: false
    network: traefik-public

certificatesResolvers:
  letsencrypt:
    acme:
      email: [email protected]
      storage: /acme/acme.json
      httpChallenge:
        entryPoint: web

Breaking this down:

api.dashboard: true enables the Traefik web UI. Without this, the dashboard is unavailable.

entryPoints.web defines the HTTP entry point on port 80. The redirections block automatically redirects all HTTP traffic to HTTPS — you define this once here and it applies globally, not per-service.

entryPoints.websecure is the HTTPS entry point on port 443.

providers.docker tells Traefik to watch the Docker socket for containers. exposedByDefault: false is important — without it, every container you start gets exposed automatically, which is a security risk. With it set to false, a container is only routed if it explicitly has the label traefik.enable=true. network: traefik-public tells Traefik which Docker network to use when it connects to containers.

certificatesResolvers.letsencrypt configures the built-in ACME client. Replace [email protected] with your real email (Let’s Encrypt uses it to notify you before a certificate expires). acme.json is where Traefik stores the issued certificates persistently. httpChallenge means Let’s Encrypt will verify domain ownership by making an HTTP request to port 80 — this is why port 80 must be open.


Step 3: Prepare the ACME Storage File

Let’s Encrypt certificates are stored in acme.json. This file must exist before Traefik starts and must have strict permissions, otherwise Traefik will refuse to write to it:

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

The chmod 600 ensures only the file owner can read or write it. Traefik will fail to start if the permissions are too open.


Step 4: Generate a Basic Auth Password for the Dashboard

The Traefik dashboard shows your entire routing configuration, active services, and TLS certificate status. You should never expose it to the public without authentication.

Install htpasswd from the Apache utilities package to generate a hashed password:

sudo apt install -y apache2-utils

Generate credentials for a user named admin:

htpasswd -nb admin yourpassword

Output will look like:

admin:$apr1$Rz0vXHyT$GqblQNXJzYiEJeY5CIr2R1

Copy this entire string — you will paste it into the Docker Compose file shortly. If the string contains $ characters, you will need to escape each $ with a second $ when it appears inside a Compose labels value (e.g., $$apr1$$Rz0vXHyT$$...).


Step 5: Create the Docker Compose File

Now write the main docker-compose.yml that runs Traefik:

nano ~/traefik/docker-compose.yml
version: "3.9"

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config/traefik.yml:/traefik.yml:ro
      - ./acme/acme.json:/acme/acme.json
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$Rz0vXHyT$$GqblQNXJzYiEJeY5CIr2R1"

networks:
  traefik-public:
    external: true

Key points to understand:

/var/run/docker.sock:/var/run/docker.sock:ro mounts the Docker socket read-only. This is how Traefik watches for container events. The :ro flag prevents Traefik from writing to the socket — only read access is needed.

./config/traefik.yml:/traefik.yml:ro mounts the static config you wrote in Step 2.

./acme/acme.json:/acme/acme.json mounts the certificate storage file without :ro because Traefik needs to write to it.

Labels on the traefik container itself configure the dashboard router:

  • traefik.http.routers.dashboard.rule=Host(...) — route traffic for traefik.example.com to the dashboard
  • traefik.http.routers.dashboard.service=api@internalapi@internal is the built-in Traefik API/dashboard service
  • traefik.http.middlewares.dashboard-auth.basicauth.users=... — attach the basic auth middleware you generated in Step 4. Remember to double the $ signs.

networks: traefik-public: external: true — the network is declared as external, meaning you must create it manually before starting the stack.

Replace traefik.example.com with your actual domain, and replace the basicauth.users value with the hash you generated.


Step 6: Create the Shared Docker Network and Start Traefik

Create the external network that Traefik and all your other services will share:

docker network create traefik-public

This network must exist before you start any container that uses it. Creating it manually as an external network (rather than letting Compose create it) means multiple Compose projects can all connect to it independently.

Now start Traefik:

cd ~/traefik
docker compose up -d

Check that it started cleanly:

docker compose logs -f traefik

Look for lines like:

time="..." level=info msg="Starting provider aggregator providers.ProvidersThrottleDuration=2s"
time="..." level=info msg="Starting server on :443"
time="..." level=info msg="Starting server on :80"

If you see errors about acme.json permissions, run chmod 600 ~/traefik/acme/acme.json and restart.

Open https://traefik.example.com in your browser. You should be prompted for the username and password you set up, and then see the Traefik dashboard showing your current routers and services.


Step 7: Deploy a Service Behind Traefik

Now deploy a simple test container to verify that routing and SSL work correctly. The traefik/whoami image is a tiny HTTP server that returns request metadata — perfect for testing.

Create a second Compose project:

mkdir ~/whoami && cd ~/whoami
nano docker-compose.yml
version: "3.9"

services:
  whoami:
    image: traefik/whoami:latest
    restart: unless-stopped
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"

networks:
  traefik-public:
    external: true

Notice: this service does not expose any ports directly. Port exposure is entirely handled by Traefik. The only thing connecting it to Traefik is the shared traefik-public network and the labels.

The labels say: “I am enabled for Traefik; route requests for whoami.example.com to me on the websecure entry point; use the letsencrypt resolver for TLS.”

Start it:

docker compose up -d

Within a few seconds, Traefik detects the new container and requests an SSL certificate from Let’s Encrypt. Then test it:

curl https://whoami.example.com

Output:

Hostname: f3d8a1b2c4e5
IP: 172.20.0.3
RemoteAddr: 172.20.0.2:54312
GET / HTTP/1.1
Host: whoami.example.com
...

You have a fully routed, HTTPS-terminated service — with a real certificate — and you did not touch the Traefik config at all after the initial setup.


Step 8: Add a Middleware — Strip Path Prefix

Middlewares let you transform requests before they reach your service. A common real-world use case is stripping a path prefix, useful when you route by path rather than hostname.

Here is how to define and apply a StripPrefix middleware in labels:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.myapp.rule=Host(`app.example.com`) && PathPrefix(`/api`)"
  - "traefik.http.routers.myapp.entrypoints=websecure"
  - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
  - "traefik.http.routers.myapp.middlewares=strip-api-prefix"
  - "traefik.http.middlewares.strip-api-prefix.stripprefix.prefixes=/api"

With this config, a request to https://app.example.com/api/users arrives at the container as /users — the /api prefix is stripped before forwarding.


Common Mistakes and Troubleshooting

Certificate not issued, browser shows “certificate invalid”:

Let’s Encrypt’s HTTP challenge requires port 80 to be reachable from the internet. Check your firewall (sudo ufw status), confirm port 80 is open, and check your DNS record actually points to this server. Run docker compose logs traefik 2>&1 | grep acme to see ACME-related log entries.

Dashboard returns 404 or “page not found”:

The most common cause is a typo in the api@internal service name, or forgetting to set api.dashboard: true in traefik.yml. Double-check both and restart Traefik.

Container not picked up by Traefik:

Verify the container has traefik.enable=true in its labels and is on the traefik-public network. Run docker inspect <container_name> and check the Networks section. Also check that the network setting in traefik.yml matches (traefik-public).

acme.json permission error on startup:

level=error msg="Unable to create ACME provider" error="unable to open ACME storage: open /acme/acme.json: permission denied"

Run chmod 600 ~/traefik/acme/acme.json on the host and restart the container.

Double-dollar sign confusion in basic auth:

In a Docker Compose file, $ is a special character for variable substitution. Every $ in your htpasswd hash must be doubled to $$. If you forget this, Compose silently strips them and the password hash becomes invalid.

Let’s Encrypt rate limits:

Let’s Encrypt has a limit of 5 failed validation attempts per hour per domain, and 50 certificates per domain per week. While testing, set caServer: https://acme-staging-v02.api.letsencrypt.org/directory in your certificatesResolvers block to use the staging environment. Remove it when everything works to get a real certificate.


Best Practices

Never expose the Docker socket without precautions. Mounting /var/run/docker.sock gives Traefik full control over Docker. Use :ro (read-only) to limit the blast radius. For hardened environments, consider a Docker socket proxy like tecnativa/docker-socket-proxy that exposes only the events and containers endpoints Traefik actually needs.

Use exposedByDefault: false. This is already in the config above, but it is worth emphasizing. With it off, only containers with traefik.enable=true are routed. Without it, every container you start gets a route, which is an accidental exposure risk.

Keep acme.json backed up. If you lose it and Let’s Encrypt rate-limits you, you will be locked out of certificate issuance for a week. Back it up regularly or store it on a persistent volume in a cloud environment.

Specify the container port explicitly when a container exposes multiple ports. If your container exposes ports 8080 and 9090, Traefik does not know which one to route to and will pick one arbitrarily. Be explicit:

- "traefik.http.services.myapp.loadbalancer.server.port=8080"

Use separate Compose projects per application. The pattern used in this tutorial — Traefik in its own Compose project, each application in its own — is much easier to manage than putting everything in one giant file. Each application can be started, stopped, and updated independently.

Pin Traefik to a specific version. traefik:v3.0 is safer than traefik:latest. Major versions (v2 → v3) contain breaking changes in label syntax and configuration format.


Conclusion

You now have Traefik v3 running as a container-native reverse proxy on Ubuntu. It watches for Docker containers, automatically routes traffic based on labels defined on each service, and handles SSL certificate issuance and renewal through Let’s Encrypt — all without you touching the Traefik config after the initial setup.

The key insight is that routing configuration lives with the service, not in the proxy. When you deploy a new container, you tell Traefik about it through labels. When you remove it, the route disappears. This makes Traefik well-suited for any environment where services come and go frequently.

Good next steps from here:

  • Combine with Docker Compose stacks — if you are already running multi-container apps with Compose (Getting Started with Docker and Docker Compose on Ubuntu), just add the Traefik labels and the traefik-public network to each service
  • Add rate limiting — use the built-in rateLimit middleware to protect your APIs from abuse
  • Explore Traefik with Kubernetes — Traefik supports Kubernetes IngressRoute CRDs as an alternative to the standard Ingress API (the blog already has a comparison at Kubernetes Gateway API vs Ingress)
  • Monitor Traefik — Traefik exposes Prometheus metrics out of the box; wire it into the Prometheus and Grafana setup at Set Up Prometheus and Grafana on Ubuntu to get dashboards on request rates, error rates, and certificate expiry