Traefik Reverse Proxy on a VPS: Docker Labels + Auto HTTPS
If you're running more than one app in Docker on the same VPS, you've probably hit this: every new container needs its own Nginx vhost, its own Certbot cron job, and a reload every time something changes. Traefik gets rid of that busywork. It watches Docker directly, picks up new containers from their labels, and issues Let's Encrypt certificates automatically - no config file to touch when you deploy app number six.
Why Traefik Instead of Nginx or Caddy
Nginx and Caddy are both solid reverse proxies, but they're static by design - you edit a config file, then reload the service. Traefik is built around service discovery: it talks to the Docker API, sees which containers are running, and builds its routing table from labels on those containers. Add a container with the right labels and Traefik picks it up within seconds. Stop it, and the route disappears. No reload, no stale vhost files.
| Feature | Nginx | Caddy | Traefik |
|---|---|---|---|
| Config source | Static files | Static Caddyfile | Docker labels (dynamic) |
| Auto HTTPS | Manual + Certbot | Built in | Built in |
| Reload on new app | Required | Required | Not required |
| Best fit | Single app / fine control | Simple static sites | Multiple Docker containers |
If you're only ever running one WordPress site on a VPS, Nginx or Caddy is honestly simpler. Traefik earns its keep once you're juggling several containerized apps - staging environments, internal tools, client projects - on one box.
What You'll Need
- A VPS with Docker and Docker Compose v2 installed
- A domain (or subdomain) with an A record pointing at the VPS's public IP
- Ports 80 and 443 open in your firewall (
ufw allow 80/tcp && ufw allow 443/tcp) - Root or sudo SSH access
Step 1: Create the Traefik Docker Compose File
Make a directory for your reverse proxy stack, separate from your application containers:
mkdir -p ~/traefik/letsencrypt
cd ~/traefik
touch acme.json
chmod 600 acme.json
That acme.json file is where Traefik stores your issued certificates and private keys. The 600 permission isn't optional - Traefik will refuse to start if it's world-readable.
Now create docker-compose.yml:
services:
traefik:
image: traefik:v3.1
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=you@yourdomain.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
networks:
- proxy
networks:
proxy:
name: proxy
external: false
Two lines matter more than the rest: providers.docker.exposedbydefault=false means Traefik ignores every container unless it's explicitly labeled, and the tlschallenge resolver handles the ACME/Let's Encrypt handshake without you running Certbot separately.
Step 2: Start Traefik
docker compose up -d
docker compose logs -f traefik
You should see Traefik start and bind to ports 80 and 443. If it exits immediately, it's almost always the acme.json permissions or a port already in use by Apache/Nginx from a previous setup - check with ss -tlnp | grep -E ':80|:443'.
Step 3: Add Your First App with Labels
Here's a sample app - say a Node app or a WordPress container - joining the same proxy network with routing labels instead of a vhost file:
services:
myapp:
image: myapp:latest
container_name: myapp
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls.certresolver=myresolver"
- "traefik.http.services.myapp.loadbalancer.server.port=3000"
networks:
proxy:
external: true
The loadbalancer.server.port label tells Traefik which port the container listens on internally - it does not need to be published with ports: at all, since Traefik reaches it over the shared Docker network.
docker compose up -d
Within a few seconds, hit https://app.yourdomain.com. Traefik should have already requested and installed a certificate - check docker compose -f ~/traefik/docker-compose.yml logs traefik for a line confirming the ACME certificate was obtained.
Common Problems and Fixes
"404 page not found" from Traefik itself
This means Traefik is running but no router matched your request. Usually it's a mismatched Host() rule, a missing traefik.enable=true label, or the app container isn't on the proxy network. Run docker network inspect proxy and confirm both Traefik and your app show up.
Certificate never issues
Check three things in order: DNS actually resolves to this VPS (dig +short app.yourdomain.com), port 80 is reachable from the outside (not blocked by a cloud firewall in front of UFW), and you haven't hit Let's Encrypt's rate limit from repeated failed attempts. If you've been testing a lot, switch temporarily to the staging CA (--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory) so failures don't burn your production rate limit.
Dashboard exposed to the internet
If you enabled --api.insecure=true to peek at the dashboard while testing, remove it before going live - that flag serves the dashboard on port 8080 with no authentication. Either drop the flag entirely or put the dashboard behind its own router with basic auth middleware.
Containers on different networks can't be routed
Every app Traefik should route to needs to join the same external network you created for the proxy. A container on the default Compose network is invisible to Traefik even with correct labels.
Prevention: Keep It Stable Long-Term
- Pin the Traefik image to a specific version (
traefik:v3.1, not:latest) so an upstream update doesn't change your routing behavior overnight. - Back up
acme.jsonoff the server - losing it means re-issuing every certificate and risking rate limits. - Keep
exposedbydefault=falseso a stray container never gets accidentally routed to the public internet. - Restrict the Docker socket mount to read-only (
:ro), as shown above - Traefik only needs to read container metadata, never control containers.
Once this is set up, adding a new app to your VPS is a docker compose file and two labels - no touching the reverse proxy at all. If you'd rather not manage any of this yourself, Getwebup's managed VPS plans include Docker and reverse proxy setup as part of onboarding.
Frequently asked questions
Do I need Nginx or Apache installed alongside Traefik?
No. Traefik replaces them as the entry point for HTTP/HTTPS traffic on ports 80 and 443. If Nginx or Apache is already bound to those ports from a previous setup, stop and disable that service first, or Traefik won't be able to start.
Can Traefik route to a non-Docker app, like a WordPress site running directly on the VPS via cPanel?
Yes, using the file provider instead of the Docker provider - you'd define a static router pointing to 127.0.0.1:<port>. It's more manual setup than label-based Docker routing, so if most of your sites are traditional LAMP/cPanel hosting, Nginx or Caddy is usually simpler.
What happens to my certificates if I rebuild the Traefik container?
Nothing, as long as the ./letsencrypt folder (containing acme.json) is mounted as a volume like in the compose file above. Certificates persist across container restarts and rebuilds - they're only lost if you delete that folder.
Is the TLS challenge method reliable behind Cloudflare's proxy?
It can be flaky, because Cloudflare's proxy terminates TLS before it reaches your VPS. If your domain is proxied (orange cloud) through Cloudflare, switch to the DNS-01 challenge with Traefik's Cloudflare plugin instead of tlschallenge, or set the DNS record to 'DNS only' while certificates are being issued.