A reverse proxy with Caddy or Traefik
A reverse proxy lets several containerised apps share ports 80 and 443 by routing requests to the right backend based on hostname or path. This guide compares Traefik and Caddy, then walks through a complete Traefik deployment with automatic TLS certificates.
You run one reverse proxy on your VPS that owns ports 80 and 443, terminates TLS once, then routes each request to the right container by hostname or path. Traefik and Caddy both do this: Traefik matches rules like Host("app.example.com") on 443, Caddy uses site blocks with reverse_proxy. Below we compare both, then build a working Traefik setup you can drop into Docker Compose.
Before you start
This picks up from a server you can already reach over SSH.
- A Hostworld VPS running AlmaLinux 9 or Ubuntu 24.04.
- Root or sudo access.
- Docker and Docker Compose installed. Your containers will run on a user-defined Docker network so the proxy can resolve them by service name. Compose’s default project network is fine for this.
- One or more DNS hostnames, for example app1.example.com and app2.example.com, with A and AAAA records pointing at your VPS public IP.
- Ports 80 and 443 reachable from the Internet. Only one process can bind to each of these. Stop any other web server that is already listening on 0.0.0.0:80 or 0.0.0.0:443, or change its bind address.
- If you use Cloudflare’s orange cloud proxy on your DNS records, Let’s Encrypt’s HTTP-01 challenge will fail because the validator reaches Cloudflare, not your VPS. You will need DNS-01 or TLS-ALPN-01 instead, or turn the proxy off while issuing.
- Certificate storage must persist across restarts. For Traefik this is an
acme.jsonfile on disk with permission mode 600. Deleting it forces fresh issuance and can hit Let’s Encrypt rate limits faster than before. - WebSockets work through both Traefik and Caddy without extra flags.
Step 1: Choose your reverse proxy
Pick one and stick with it for a given host. Both take control of ports 80 and 443.
- Traefik: discovers Docker services and routes by rules you place in Docker labels. The fixed decision path is Client → EntryPoint (443) → Router (rule like Host(...)) → optional Middleware → Service (load balancer) → your container. It integrates certificate management and redirects HTTP to HTTPS. The v2 to v3 change kept Docker label syntax largely compatible, so many older examples still work with minor adjustments.
- Caddy: uses a Caddyfile where each site block represents a hostname. A minimal block like
app1.example.com { reverse_proxy app1:8080 }is enough. Caddy enables HTTPS and HTTP→HTTPS redirects automatically for real domains. Since v2.11, when proxying to an HTTPS upstream, it rewrites the upstream Host header by default. Backends that expect the original client Host may needheader_up Host {host}to keep prior behaviour.
We show Traefik in full because it scales neatly with lots of containers driven by labels. There is a Caddy outline later if you prefer a Caddyfile.
Step 2: Point DNS at your VPS
Let’s Encrypt needs to reach your VPS to validate and to serve real traffic afterwards.
- Create A and AAAA records for each hostname you plan to use, for example:
- app1.example.com → your VPS IPv4 and IPv6
- app2.example.com → your VPS IPv4 and IPv6
- If these records are proxied by Cloudflare (orange cloud), HTTP-01 will fail. Use DNS-01, TLS-ALPN-01, or turn off the proxy until certificates are issued.
Step 3: Open the firewall for HTTP and HTTPS
You need to allow inbound TCP/80 and TCP/443 on the VPS firewall.
AlmaLinux 9 (firewalld)
These commands add the HTTP and HTTPS services permanently, then reload the rules.
sudo firewall-cmd --add-service=http --permanent
sudo firewall-cmd --add-service=https --permanent
sudo firewall-cmd --reload
Ubuntu 24.04 (UFW)
These commands allow SSH to keep your session, then allow TCP/80 and TCP/443 for web traffic.
sudo ufw allow OpenSSH
sudo ufw allow proto tcp from any to any port 80,443
# Enable UFW if it is not already enabled:
# sudo ufw enable
Only enable UFW after confirming SSH is allowed. Locking yourself out is avoidable.
Step 4: Prepare persistent certificate storage
Traefik stores Let’s Encrypt state in a JSON file. Create a directory and file for it, then set strict permissions. The commands are the same on both AlmaLinux and Ubuntu.
These commands create a folder, create acme.json, and restrict it to your user so Traefik can write it.
mkdir -p ~/reverse-proxy/letsencrypt
touch ~/reverse-proxy/letsencrypt/acme.json
chmod 600 ~/reverse-proxy/letsencrypt/acme.json
Keep this file across restarts and upgrades. Do not delete it unless you truly intend to re-issue certificates. Let’s Encrypt tightened rate limits in 2026, so repeated re-issuance can fail sooner than before. If you want to test routing first, point Traefik at the Let’s Encrypt staging CA until your labels are correct.
Step 5: Create a working Traefik Compose file
We will run Traefik, plus two example apps. Replace hostnames, emails and images with your own. Compose will create a user-defined network for the project and attach all services to it, so that names like app1 resolve from the proxy.
Save this as ~/reverse-proxy/docker-compose.yml.
services:
traefik:
image: traefik:v3.4
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
command:
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "[email protected]"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
- "--entrypoints.websecure.http.tls.certresolver=le"
app1:
image: yourorg/app1
labels:
- "traefik.enable=true"
- "traefik.http.routers.app1.rule=Host(`app1.example.com`)"
- "traefik.http.routers.app1.entrypoints=websecure"
- "traefik.http.services.app1.loadbalancer.server.port=8080"
app2:
image: yourorg/app2
labels:
- "traefik.enable=true"
- "traefik.http.routers.app2.rule=Host(`app2.example.com`)"
- "traefik.http.routers.app2.entrypoints=websecure"
- "traefik.http.services.app2.loadbalancer.server.port=3000"
Notes that matter:
- Traefik is the only service publishing ports 80 and 443. Your apps are private on the Docker network and will be reached by their service name and internal port.
--providers.docker.exposedbydefault=falseensures nothing is published until you settraefik.enable=trueon a service.- The routers use
Host(...)rules on thewebsecureentrypoint. The certificate resolver attaches to that entrypoint so valid TLS is presented. - Traefik writes certificates to
./letsencrypt/acme.json. That folder is the one you created in the previous step. Keep the file and its 600 mode. - Compose’s default project network is a user-defined bridge that includes embedded DNS. Containers on this network can resolve one another by service name, such as
app1andapp2. Do not place Traefik and your apps on different or legacy default bridge networks.
Step 6: Start the stack and let certificates issue
Start the Compose project from the directory that holds your docker-compose.yml. Traefik will bind to 80 and 443, redirect HTTP to HTTPS, and obtain a certificate for each hostname by HTTP-01. Ensure ports 80 and 443 are open on the VPS and that the DNS hostnames really point to this VPS.
If ports 80 or 443 are already in use by another process, Traefik cannot start. Stop the conflicting process or change its bind address. If you cannot allow inbound 80 due to network policy, switch to TLS-ALPN-01 or DNS-01 in Traefik instead of HTTP-01.
Step 7: Add more apps or different rules
You can add more services by repeating the pattern: enable Traefik on the service, add a router rule matching a hostname (or a path prefix), and set the internal port. Restart the service to apply labels. Keep the services on the same user-defined network as Traefik so names resolve.
Path-based routing is also possible. For example, a router could match Host(`app.example.com`) && PathPrefix(`/api`) and send only those requests to one backend, with a different router handling /. Plan these rules so they do not overlap in surprising ways.
Step 8: Use Caddy instead (outline)
If you prefer a Caddyfile, the minimal approach is very short. Caddy enables HTTPS and redirects automatically when your site blocks use real domains and ports 80 and 443 are reachable. Place the proxy and your apps on the same user-defined Docker network so upstream names like app1 resolve.
app1.example.com {
reverse_proxy app1:8080
}
app2.example.com {
reverse_proxy app2:3000
}
- Make sure your DNS hostnames point at your VPS and the firewall allows HTTP and HTTPS.
- WebSockets work through
reverse_proxywithout extra options. - If you proxy to an HTTPS upstream and your backend relied on the original client Host, review Caddy v2.11’s change to upstream Host rewriting. You may need
header_up Host {host}to keep the old behaviour.
Step 9: Understand how the proxy decides “which container gets this request”
Traefik uses a fixed pipeline you can keep in your head when debugging:
- Client: connects to your VPS and sends SNI and Host header.
- EntryPoint: Traefik receives the connection on an entrypoint, for example
websecureon port 443. - Router: Traefik evaluates router rules, such as
Host(`app1.example.com`), and picks the best match on that entrypoint. - Middleware (optional): request or response modifications, for example headers.
- Service: the router forwards to a named service, which is a load balancer pointing at your backend.
- Container: the service sends the request to the container on its internal port. Because everything is on the same user-defined network, names like
app1resolve to the right container.
Caddy’s model is similar in effect. Each site block matches a hostname, and its reverse_proxy directive selects the upstream by name and port on the same network.
Step 10: Avoid the common pitfalls
- Port 80 blocked: HTTP-01 cannot validate without public TCP/80. Either allow 80 or use TLS-ALPN-01 on 443, or DNS-01.
- Cloudflare orange cloud: with the proxy on, HTTP-01 hits Cloudflare, not your VPS, and fails. Use DNS-01 or TLS-ALPN-01, or turn the proxy off while issuing.
- Certificate state lost: deleting or not persisting
acme.jsonforces new certificates on each restart. You can hit Let’s Encrypt rate limits. Keep the file and mode 600. - Wrong Docker network: Traefik and your apps must share the same user-defined network. The legacy default bridge breaks container-name routing and yields 404 or 502.
- Port conflicts: only one process can listen on 0.0.0.0:443. Stop other listeners before starting the proxy.
- Old Traefik examples: most v2 label syntax works in v3, but some changes exist. Check the current docs if an example does not behave as expected.
What next
If you want a managed starting point, our London VPS and our US plans both work well for this pattern. You can scale the same hostnames and rules across additional containers on the same VPS network. For more articles like this, see our VPS guides.
If you are stuck on a step or a certificate is not issuing, open a support ticket. Mention the hostnames you used and whether ports 80 and 443 are reachable from the Internet. If you need to reboot your VPS while experimenting, you can do that in Virtualizor from the Hostworld client area.
Common questions
Do I need to open both 80 and 443?
Yes, if you use HTTP-01 for Let’s Encrypt. The validator must reach port 80 to complete the challenge. If you cannot allow 80, switch to TLS-ALPN-01 on 443 or DNS-01 in your proxy configuration.
Can I keep my existing web server on the same VPS?
Only if it does not listen on 0.0.0.0:80 or 0.0.0.0:443. The reverse proxy must bind those ports. You can run another server on a different port or on localhost and have the proxy route to it. Do not try to have two processes share 443.
What happens if I delete Traefik’s acme.json?
Traefik will attempt to re-issue certificates on next start. That can hit Let’s Encrypt rate limits, which are stricter than they used to be. Keep acme.json persistent and at mode 600. If you need to test routing first, use the Let’s Encrypt staging CA until your rules are correct.
How do containers find each other by name?
Docker’s embedded DNS works on user-defined networks, including the default network that Compose creates for each project. Traefik and your apps must be on the same user-defined network for names like app1 to resolve. The legacy default bridge does not provide this and will break container-name routing.
Will WebSockets work through the proxy?
Yes. Both Traefik and Caddy support WebSockets through their reverse proxy features without extra flags.
Why do my backends get the wrong Host header with Caddy?
Since Caddy v2.11, when proxying to an HTTPS upstream, Caddy sets the upstream Host header to the upstream’s host and port by default. If your app expected the original client Host for its own routing or authentication, set header_up Host {host} in your Caddyfile to restore the earlier behaviour.
If you need hands-on help on any of this, please open a support ticket so we can look with you.