Traefik: My Reverse Proxy for VPS and On-Prem

Traefik: My Reverse Proxy for VPS and On-Prem

If you run more than one service on a server, you quickly run into a problem: you only have one IP address and a limited number of ports, but you want clean URLs like ghost.yourdomain.com and keycloak.yourdomain.com, not yourip:8080 and yourip:8443. You also want real HTTPS certificates on everything without manually running Certbot for each service.

This is the problem a reverse proxy solves — and Traefik is the one I've settled on for both my Hetzner VPS and my on-premises homelab.

What Is Traefik?

Traefik is a modern, cloud-native reverse proxy and load balancer written in Go. Unlike older options like Nginx or HAProxy, Traefik was built specifically to integrate with container runtimes. When you add a new Docker container with the right labels, Traefik automatically picks it up, configures routing, and — if you've set up a certificate resolver — provisions a TLS certificate for it. No manual config reload, no touching a config file for every new service.

Key things that make it stand out:

  • Docker-native autodiscovery — reads labels directly off containers
  • Automatic HTTPS via Let's Encrypt (HTTP challenge, TLS-ALPN, or DNS challenge)
  • Dynamic configuration — changes take effect instantly, no restart needed
  • Middleware system — composable security headers, rate limiting, IP allowlists, basic auth, and more
  • Built-in dashboard for visibility into routers, services, and certificates
  • File provider — for non-Docker services (bare metal, VMs, other hosts)

My Setup: Two Environments, One Consistent Approach

I run Traefik in two places:

  1. A Hetzner VPS — public-facing services exposed to the internet
  2. On-premises homelab — internal services on my home network

Both use the same Docker Compose structure and the same Cloudflare DNS challenge for certificates. This means I get valid, trusted TLS certificates on internal services too — no "certificate not trusted" browser warnings on my homelab — because the DNS challenge doesn't require the server to be publicly reachable.

Services Behind Traefik on the VPS

My current public-facing stack includes:

  • Ghost — this blog
  • Keycloak — identity provider / SSO
  • Netbird — WireGuard-based overlay network management
  • Nginx — static website hosting
  • Umami — privacy-respecting web analytics

Each of these runs as a Docker container on a shared traefik-network. Traefik handles all TLS termination, so the services themselves don't need to deal with certificates at all.

What Running It On-Prem Enables

The on-prem instance is where things get particularly useful. Because I use the Cloudflare DNS challenge (not HTTP challenge), Traefik can issue valid Let's Encrypt wildcard certificates for services that are completely internal — never exposed to the internet. This means I can reach services like a local dashboard, network monitoring tools, or a home media server at https://service.home.yourdomain.com with a proper trusted certificate.

The file provider in Traefik's dynamic config is especially handy here. For anything that isn't a Docker container — a bare-metal NAS, a VM running a different stack, an old service on a fixed port — I just add a router and service block in config.yaml, point it at the internal IP and port, and Traefik picks it up instantly. My homelab is a mix of Docker services and bare-metal machines, and Traefik handles both cleanly under a single entry point.

Combined with my Netbird overlay network (more on that in a separate post), this means I can securely reach internal services from anywhere without exposing them publicly.

The Architecture

Here's a simplified view of how traffic flows on the VPS:

Internet
    │
    ▼
  :80 / :443
    │
  Traefik
    ├── ghost.yourdomain.com ──► Ghost container
    ├── keycloak.yourdomain.com ──► Keycloak container
    ├── netbird.yourdomain.com ──► Netbird container
    ├── analytics.yourdomain.com ──► Umami container
    └── www.yourdomain.com ──► Nginx container

All HTTP traffic on port 80 is immediately redirected to HTTPS. TLS is terminated at Traefik, and traffic to each service travels over the internal Docker network unencrypted — which is fine since it never leaves the host.

The Files

The full repo is on GitHub (link at the end). Here's a walkthrough of each file.

docker-compose.yml

This is the main entry point. A few things worth highlighting:

Secrets over environment variables. The Cloudflare API token is passed as a Docker secret (cf-token file) rather than a plain environment variable. Traefik reads it via CF_DNS_API_TOKEN_FILE=/run/secrets/cf-token. Secrets are mounted as tmpfs inside the container and don't show up in docker inspect output the way plain env vars do.

Dashboard security. The dashboard is exposed at traefik.yourdomain.com behind a middleware chain — both an IP allowlist and basic auth are required. Neither alone is sufficient: basic auth is brute-forceable, and IP allowlisting alone doesn't protect against someone on your network. Together they make the dashboard effectively inaccessible to anyone who isn't you.

Volumes. The config files are mounted read-only (:ro). The only writable mount is acme.json, which Traefik manages itself for certificate storage, and the logs directory.

secrets:
  cf-token:
    file: ./cf-token
services:
  traefik:
    image: traefik:latest
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    secrets:
      - cf-token
    env_file:
      - .env
    networks:
       traefik-network:
    ports:
      - 80:80
      - 443:443
    environment:
      - TRAEFIK_DASHBOARD_CREDENTIALS=${TRAEFIK_DASHBOARD_CREDENTIALS}
      - CF_DNS_API_TOKEN_FILE=/run/secrets/cf-token
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config/traefik.yaml:/traefik.yaml:ro
      - ./config/acme.json:/acme.json
      - ./config/config.yaml:/config.yaml:ro
      - ./logs:/var/log/traefik
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-secure.entrypoints=https"
      - "traefik.http.routers.traefik-secure.rule=Host(`traefik.yourdomain.com`)"
      - "traefik.http.routers.traefik-secure.tls=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.traefik-secure.tls.domains[0].main=yourdomain.com"
      - "traefik.http.routers.traefik-secure.tls.domains[0].sans=*.yourdomain.com"
      - "traefik.http.routers.traefik-secure.service=api@internal"
      - "traefik.http.routers.traefik-secure.middlewares=dashboard-secured-chain"
      - "traefik.http.middlewares.dashboard-secured-chain.chain.middlewares=dashboard-ip-allowlist,dashboard-basic-auth"
      - "traefik.http.middlewares.dashboard-ip-allowlist.ipallowlist.sourceRange=127.0.0.1/32, YOUR_TRUSTED_IP/32"
      - "traefik.http.middlewares.dashboard-basic-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}"

networks:
  traefik-network:
    external: true

config/traefik.yaml — Static Config

This is Traefik's static configuration — things that require a restart to change. The key parts:

Entry points. Port 80 (http) redirects to HTTPS automatically. Port 443 (https) is the main entry point. I've disabled the responding timeouts on HTTPS to avoid issues with long-lived connections (relevant for services like Netbird that maintain persistent connections).

Providers. Two are configured: docker (reads container labels) and file (reads config.yaml for static service definitions). exposedByDefault: false means containers are invisible to Traefik unless they explicitly opt in with traefik.enable=true.

Certificate resolver. The cloudflare resolver uses Let's Encrypt's DNS-01 challenge via Cloudflare's API. This is what enables wildcard certs and works for internal services. The resolvers are set to Cloudflare's own DNS (1.1.1.1 and 1.0.0.1) to avoid propagation issues.

certificatesResolvers:
  cloudflare:
    acme:
      caServer: https://acme-v02.api.letsencrypt.org/directory
      email: your@email.com
      storage: acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"

config/config.yaml — Dynamic Config

This file is watched by Traefik's file provider and reloaded on change — no restart needed. I use it for two things:

Security headers middleware. This sets a solid default set of HTTP security headers (HSTS, X-Content-Type-Options, Referrer-Policy, CSP, X-Forwarded-Proto) that I can apply to any service just by referencing default-security-headers@file in a Docker label. According to SSL Labs, this configuration produces an A+ rating.

Non-Docker service routing. The commented-out example shows how to expose a service at a fixed IP/port (e.g., something running on bare metal or a different VM) without it being a Docker container. This is how I expose on-prem services through the homelab Traefik instance.

Deployment

1. Create the network

All services that Traefik should proxy must share a Docker network:

docker network create traefik-network

2. Set up secrets

cp .env.example .env
cp cf-token.example cf-token

Generate a bcrypt password for the dashboard:

echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g'

Paste the output as the value of TRAEFIK_DASHBOARD_CREDENTIALS in .env. Paste your Cloudflare API token (with Zone:DNS:Edit permission) into cf-token.

3. Prepare acme.json

touch config/acme.json
chmod 600 config/acme.json

The chmod 600 is required — Traefik will refuse to start if this file has looser permissions, since it contains private key material.

4. Update your domain and trusted IP

In docker-compose.yml, replace yourdomain.com with your actual domain and YOUR_TRUSTED_IP with the IP from which you'll access the dashboard. In config/traefik.yaml, update the Let's Encrypt email.

5. Deploy

docker compose up -d

Adding a New Service

Once Traefik is running, adding a new service is just a matter of putting it on traefik-network and adding labels to its container. Here's a minimal example:

networks:
  - traefik-network

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.myapp.entrypoints=https"
  - "traefik.http.routers.myapp.rule=Host(`myapp.yourdomain.com`)"
  - "traefik.http.routers.myapp.tls=true"
  - "traefik.http.routers.myapp.tls.certresolver=cloudflare"
  - "traefik.http.routers.myapp.middlewares=default-security-headers@file"
  - "traefik.http.services.myapp.loadbalancer.server.port=8080"

That's it. Traefik picks it up instantly, requests a certificate if needed, and the service is live at https://myapp.yourdomain.com.

What's Next: Moving to acme-dns

The current setup depends on a Cloudflare API token with DNS edit permissions. That's a reasonable trade-off — Cloudflare's API is reliable and the token scope is narrow — but I'd prefer not to have any cloud provider dependency for certificate issuance.

I recently set up a self-hosted acme-dns server (see that post for details). The plan is to migrate this Traefik deployment to use acme-dns for the ACME DNS-01 challenge instead of the Cloudflare provider. This removes the Cloudflare token entirely and gives me full control over the certificate issuance chain. I'll post an update when that's done.

Files

The full anonymized config is on GitHub: github.com/jevellangelo/traefik

The repo includes docker-compose.yml, config/traefik.yaml, config/config.yaml, example env/secret files, and a .gitignore that ensures you can't accidentally commit your actual token or certificate store.

GitHub - jevellangelo/traefik
Contribute to jevellangelo/traefik development by creating an account on GitHub.