Traefik with Docker Compose, and why I write its config in TOML

September 2026 Luis Vera

Traefik sits in front of almost everything on my server. It terminates TLS, gets certificates, and sends each hostname to the right container. I configure it from two places at once, and that split has held up well.

Static config: one TOML file

The static config is what Traefik reads at startup: entry points, providers, certificate resolvers, plugins. Mine is a single traefik.toml. Trimmed down, it looks like this:

[providers.docker]
  endpoint = "unix:///var/run/docker.sock"
  exposedByDefault = false
  network = "proxy"

[providers.file]
  directory = "/etc/traefik/dynamic"
  watch = true

[entryPoints.web]
  address = ":80"

[entryPoints.websecure]
  address = ":443"
  [entryPoints.websecure.http3]
    advertisedPort = 443

[certificatesResolvers.gts.acme]
  caServer = "https://dv.acme-v02.api.pki.goog/directory"
  storage = "/etc/traefik/SSL/gts.json"
  [certificatesResolvers.gts.acme.dnsChallenge]
    provider = "cloudflare"

If I could keep only one line, it would be exposedByDefault = false. Without it, every container Traefik can see on the Docker socket gets a route. With it, nothing is public until I say so.

Certificates come from Google Trust Services over ACME, using a DNS challenge through Cloudflare. Validation never depends on the HTTP path, which helps when Cloudflare sits in front of the origin. Google’s CA also needs an EAB key, and that goes in the same resolver block.

Simple routes: Docker labels

For an ordinary web app, the route lives on the container, in its own compose file:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.app.rule=Host(`app.example.com`)"
  - "traefik.http.routers.app.entrypoints=websecure"
  - "traefik.http.routers.app.tls.certresolver=gts"
  - "traefik.http.services.app.loadbalancer.server.port=8001"
networks:
  - proxy

The route comes and goes with the container. When I delete a stack, its hostname disappears with it, and there’s no separate proxy file to forget about.

Everything else: a watched folder

Middlewares I reuse, TLS options, and routes to things that aren’t containers on this host go into TOML files under dynamic/. Traefik watches that folder and reloads when a file changes. No restart.

[http.middlewares.https-redirect.redirectScheme]
  scheme = "https"
  permanent = true

[http.middlewares.cloudflare-only.ipAllowList]
  sourceRange = [
    "173.245.48.0/20",
    "103.21.244.0/22",
    # ...the rest of Cloudflare's published ranges
  ]

[tls.options.default]
  minVersion = "VersionTLS12"
  alpnProtocols = ["h3", "h2", "http/1.1"]

Labels refer to these with a provider suffix, like middlewares=https-redirect@file. That one convention is what lets the two sources work together. Shared policy lives in files, and routing for each app lives on the app.

Why TOML

Traefik reads TOML or YAML for both kinds of config. I use TOML for anything I edit by hand.

Indentation doesn’t mean anything. In YAML, a list item two spaces off quietly becomes part of a different key, and you find out when a route vanishes. In TOML the table header tells you where you are every time. [http.middlewares.https-redirect.redirectScheme] can’t be misread. The indentation in my files is there for my eyes only.

Types are explicit. Strings are quoted, booleans are true or false, and that’s it. YAML has a long history of guessing types (the classic is a bare no being read as false), and while modern parsers guess less, I’d rather not have to wonder.

It isn’t perfect. Deep nesting means long table headers repeated over and over, and arrays of tables with [[double brackets]] take a minute to read the first time. One of my stacks ships its routes as a YAML file, and Traefik reads it from the same folder without complaint. Mixing is fine. Pick the format you’re least likely to break on a tired evening.

Two things that bit me

Router priority. By default Traefik ranks routers by the length of their rule, longest first. That’s usually right, until an old router with a broad rule and an explicit priority still matches a hostname you’ve moved somewhere else. If two routers can ever match the same host, give both an explicit priority and make the winner obvious.

Real client IPs. Behind Cloudflare, every request seems to come from a Cloudflare address unless you tell Traefik which proxies to trust. Setting forwardedHeaders.trustedIPs on the entry point to Cloudflare’s ranges fixes the access logs and anything that rate limits by IP. Pair it with an allowlist middleware like the one above, so only Cloudflare can reach the origin in the first place.

Share: