Automating SSL Certificates with Let's Encrypt and Traefik for Multiple Self-Hosted Domains

Automating SSL Certificates with Let's Encrypt and Traefik for Multiple Self-Hosted Domains

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

The moment you start running more than two or three self-hosted services — say, Nextcloud at cloud.example.com, Gitea at git.example.com, and Jellyfin at media.example.com — manually managing SSL certificates becomes a genuine chore. I used to renew Certbot certificates by hand, copy files around, and reload Nginx every 90 days. That got old fast. Now I let Traefik handle every single certificate automatically, and the whole setup takes about 20 minutes to get right the first time. This tutorial walks you through exactly how I do it.

Why Traefik Instead of Certbot Alone?

Certbot is great for a single domain on a bare-metal server, but it doesn't know anything about your Docker containers. Traefik does. It watches the Docker socket, reads container labels, and provisions and renews Let's Encrypt certificates on its own — no cron jobs, no shell scripts, no manual nginx -s reload. When a new container spins up with the right labels, Traefik immediately routes traffic to it and fetches a certificate if one doesn't already exist. That's the workflow I want: deploy a container, get HTTPS, done.

I also prefer the DNS-01 challenge over HTTP-01 for wildcard certificates and for services that aren't exposed on port 80. If your VPS is behind a strict firewall or you're running services on a private network tunneled via Tailscale or Cloudflare, DNS-01 is often your only practical option.

If you need a reliable VPS to run this stack, I've been happy with DigitalOcean Droplets for exactly this kind of workload — predictable pricing, solid uptime, and one-click firewall rules. Create your DigitalOcean account today.

Prerequisites

Project Structure

I keep Traefik in its own directory and use a shared Docker network so every other service can attach to it. Here's the layout I use:

mkdir -p /opt/traefik/data
touch /opt/traefik/data/acme.json
chmod 600 /opt/traefik/data/acme.json
touch /opt/traefik/data/traefik.yml
docker network create proxy

The acme.json file is where Traefik stores all issued certificates. The chmod 600 is mandatory — Traefik will refuse to start if the permissions are too open. The proxy network is the shared bridge that every downstream container will join.

The Traefik Static Configuration

Traefik has two layers of config: static (loaded at startup) and dynamic (updated at runtime from Docker labels or file providers). The static config lives in /opt/traefik/data/traefik.yml:

cat > /opt/traefik/data/traefik.yml << 'EOF'
api:
  dashboard: true
  insecure: false

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

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

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: proxy
  file:
    directory: /data
    watch: true

log:
  level: INFO
EOF

A few things worth calling out here. exposedByDefault: false means Traefik will ignore containers unless they explicitly opt in with the label traefik.enable=true. That's the right default — you don't want every container accidentally exposed. The redirections block on the web entrypoint forces all HTTP traffic to HTTPS automatically. And I'm using the DNS-01 challenge with the Cloudflare provider, which means I need a Cloudflare API token in the environment.

Tip: Use a scoped Cloudflare API token rather than your global API key. In the Cloudflare dashboard, create a token with Zone > DNS > Edit permissions limited to just the zone you're using. This follows the principle of least privilege and limits blast radius if the token ever leaks.

The Docker Compose File

Now the actual Compose file for Traefik at /opt/traefik/docker-compose.yml:

cat > /opt/traefik/docker-compose.yml << 'EOF'
services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    environment:
      - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./data/traefik.yml:/traefik.yml:ro
      - ./data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
      - "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik-dashboard.service=api@internal"
      - "traefik.http.routers.traefik-dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$YOURHASHHERE"

networks:
  proxy:
    external: true
EOF

Replace traefik.example.com with your actual dashboard domain. For the basicauth hash, generate it with: htpasswd -nB admin and then double every $ sign in the hash when placing it in the Compose label (Docker label parsing eats single dollar signs). Store your Cloudflare token in a .env file in the same directory:

echo "CF_DNS_API_TOKEN=your_cloudflare_token_here" > /opt/traefik/.env
chmod 600 /opt/traefik/.env

Then bring Traefik up:

cd /opt/traefik
docker compose up -d
docker compose logs -f traefik

Watch the logs. Within 30–60 seconds you should see Traefik contact Let's Encrypt and start the DNS-01 challenge. It adds a TXT record via the Cloudflare API, waits for propagation (the resolvers lines tell it which DNS servers to poll), then retrieves the certificate and stores it in acme.json.

Adding a Service — The Right Way

This is where the whole system pays off. To expose Nextcloud (or any other service) on its own subdomain with a valid certificate, you just add labels to its container and attach it to the proxy network. No touching the Traefik config at all. Here's a minimal example for an Uptime Kuma instance:

services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      - ./data:/app/data
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.uptime-kuma.rule=Host(`status.example.com`)"
      - "traefik.http.routers.uptime-kuma.entrypoints=websecure"
      - "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt"
      - "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"

networks:
  proxy:
    external: true

Deploy it with docker compose up -d and Traefik will detect the new container, request a certificate for status.example.com, and start routing traffic — all within about a minute. Every new service you add follows the exact same pattern. I currently run eight services this way: Vaultwarden, Gitea, Jellyfin, Immich, Nextcloud, Uptime Kuma, Grafana, and the Traefik dashboard itself. One Traefik instance handles every certificate for all of them.

Watch out: Let's Encrypt has a rate limit of 5 duplicate certificate requests per domain per week. During initial testing, use the staging server to avoid burning through that limit. Add caServer: https://acme-staging-v02.api.letsencrypt.org/directory under the acme: block in your traefik.yml while you're debugging, then remove it once everything works and wipe acme.json to get a real certificate.

Wildcard Certificates

If you want a single wildcard certificate covering *.example.com instead of individual certs per subdomain, DNS-01 is the only supported challenge type. Add this to your service router label:

- "traefik.http.routers.myservice.tls.domains[0].main=example.com"
- "traefik.http.routers.myservice.tls.domains[0].sans=*.example.com"

Traefik will request a single wildcard cert and reuse it for every matching subdomain. This is especially useful if you're running a lot of services and want to minimize the number of ACME requests you're making.

Checking Certificate Status

You can inspect what's inside acme.json at any time to see which certificates are stored and when they expire:

cat /opt/traefik/data/acme.json | python3 -m json.tool | grep -A5 '"domain"'

Traefik automatically renews certificates when they're within 30 days of expiry. I've never had a certificate expire on me since switching to this setup — it just works, quietly, in the background.

Hosting This Stack on a VPS

Running this whole setup on a VPS gives you a publicly accessible HTTPS stack without any port forwarding headaches at home. A 1 vCPU / 1 GB RAM Droplet on DigitalOcean handles Traefik plus four or five lightweight services with room to spare. Get dependable uptime with our 99.99% SLA, simple security tools, and predictable monthly pricing with DigitalOcean's virtual machines, called Droplets.

Wrapping Up

The combination of Traefik and Let's Encrypt DNS-01 challenges is genuinely the cleanest SSL automation I've found for multi-service Docker setups. Once the Traefik container is running, adding a new HTTPS-secured service is four labels and a network attachment — nothing more. Certificates renew automatically, HTTP redirects to HTTPS automatically, and the dashboard gives you a live view of what's routed where.

From here, I'd recommend looking at adding Authelia in front of any services that don't have their own authentication — it integrates with Traefik middleware labels just as cleanly as everything else in this stack. You might also explore Traefik's TCP and UDP routers if you want to proxy non-HTTP services like a game server or a mail server through the same setup.

Discussion