Why a reverse proxy is essential with Docker
By default, each Docker container listens on an arbitrary port: your Nextcloud is on port 8080, your Gitea on 3000, your Vaultwarden on 8200. You cannot directly expose a dozen ports in production — browsers do not accept URLs with port numbers, TLS certificates cover domain names not ports, and your firewall must stay closed on everything except 80 and 443. A reverse proxy solves all three problems at once: it becomes the single public entry point, routes traffic based on hostname or path, and handles TLS termination. The result is that app1.your-domain.com and app2.your-domain.com both point to port 443 of your VPS, and the proxy knows which container to forward each request to.
What a reverse proxy gives you in practice
- Centralized TLS termination — a single tool obtains and renews Let's Encrypt certificates for all your domains automatically without any manual steps
- Hostname-based routing —
git.your-domain.comgoes to Gitea,cloud.your-domain.comgoes to Nextcloud with no port conflicts whatsoever - Simplified firewall — only ports 80 and 443 are open to the public, all internal Docker ports remain inaccessible from the outside
- Automatic HTTP → HTTPS redirect — all unencrypted traffic is redirected with a 301 without manual configuration inside each application
- Centralized observability — access and error logs from all your applications are aggregated in one place to simplify debugging
Common prerequisites for all three solutions
Before choosing and installing one of these three proxies, make sure your VPS meets a few basic requirements. Docker and Docker Compose must be installed (Compose V2 recommended, meaning the docker compose command without a hyphen). Your DNS records must point to your VPS public IP before requesting a certificate — Let's Encrypt verifies DNS during HTTP-01 validation, and an error at this stage can trigger a rate limit. Ports 80 and 443 on your VPS must be free: check with ss -tlnp | grep -E ':80|:443'. Finally, create a dedicated Docker network — docker network create proxy.
Comparison table: Caddy vs Traefik vs Nginx Proxy Manager
| Criterion | Caddy | Traefik | Nginx Proxy Manager |
|---|---|---|---|
| Setup ease | ⭐⭐⭐⭐⭐ Very simple | ⭐⭐⭐ Medium | ⭐⭐⭐⭐⭐ Very simple |
| Automatic TLS (Let's Encrypt) | ✅ Native, zero config | ✅ Via ACME | ✅ Via web interface |
| Docker integration | Manual (labels or config) | ✅ Native labels | ✅ Via GUI |
| Configuration method | Readable Caddyfile | Docker labels + YAML | Graphical web interface |
| Zero-downtime reload | ✅ Automatic | ✅ Automatic | ✅ Automatic |
| Web interface/dashboard | ❌ No | ✅ Dashboard included | ✅ Full interface |
| Memory usage | ~30 MB | ~50 MB | ~100 MB (MariaDB included) |
| Best for | Solo projects/small teams | Microservices, CI/CD | Beginners, mixed teams |
Caddy: the default choice for most use cases
Caddy has established itself as the simplest choice for developers hosting between two and ten applications on a VPS. Its philosophy is radical: TLS is enabled by default for any valid domain, with no option to tick and no variable to set. The Caddyfile configuration file is intentionally readable, close to natural language, and can fit in about ten lines for a typical use case. Caddy is written in Go and embeds its own ACME client: it contacts Let's Encrypt directly, stores certificates in its data directory and renews them automatically before expiry. Its memory footprint stays around 30 MB at rest, making it perfectly suited to entry-level VPS instances.
Deploy Caddy as a Docker reverse proxy
Create the shared Docker network
First, create the network that Caddy and your applications will share: docker network create proxy. This isolated network lets Caddy reach your containers by name without exposing their ports on the host.
Write the Caddyfile
Create a Caddyfile at the root of your project. To expose an application at app.your-domain.com to a container named myapp listening on port 3000: app.your-domain.com { reverse_proxy myapp:3000 }. Caddy obtains the certificate automatically on first start.
Write the docker-compose.yml for Caddy
Create a docker-compose.yml with the Caddy service: mount the Caddyfile read-only (./Caddyfile:/etc/caddy/Caddyfile:ro), persist TLS data in a named volume (caddy_data:/data), publish ports 80 and 443, and attach Caddy to the proxy network declared as external.
Attach your applications to the proxy network
In each application's docker-compose.yml, add the proxy network as an external network and stop exposing ports on the host (use expose instead of ports). Caddy will reach the container via the internal Docker network.
Start and verify
Launch Caddy with docker compose up -d, then follow logs with docker compose logs -f caddy. You should see certificate obtained successfully for each domain. Test with curl -I https://app.your-domain.com.
Add a new application
For each new application, add a block to the Caddyfile, reload Caddy without downtime with docker exec caddy caddy reload --config /etc/caddy/Caddyfile, and attach the new container to the proxy network.
Traefik: when to choose automatic service discovery
Traefik shines in contexts where the number of services changes frequently: CI/CD environments that create and destroy containers on each deployment, microservice architectures with more than five independent services, or teams where each developer deploys their own stacks without touching a centralized configuration. Its automatic discovery mechanism via Docker labels is its main strength: when you start a container with the right labels, Traefik detects it instantly and creates the route without you touching Traefik's own configuration. Traefik also includes a web dashboard that visualizes all active routers, services and middlewares in real time.
Deploy Traefik with automatic Docker discovery
Create the static configuration file
Create traefik.yml with entrypoints (web on port 80, websecure on 443), enable the Docker provider (docker: { exposedByDefault: false }), configure the ACME resolver with your email for Let's Encrypt, and enable the dashboard in secure mode.
Launch Traefik with Docker Compose
In Traefik's docker-compose.yml, mount the Docker socket read-only (/var/run/docker.sock:/var/run/docker.sock:ro), mount traefik.yml, persist certificates in a volume, and publish ports 80 and 443. Start with docker compose up -d.
Annotate your containers with labels
On each service to expose, add Traefik labels: traefik.enable=true, the router rule (traefik.http.routers.myapp.rule=Host('app.your-domain.com')), the entrypoint (websecure), the TLS resolver and the internal service port.
Verify in the dashboard
Access the Traefik dashboard and verify that your router appears in green with Enabled status. If the router is missing, check that the traefik.enable=true label is present and the container is attached to the network Traefik is watching.
Critical security: never mount the Docker socket (/var/run/docker.sock) without restriction in a multi-user environment — anyone who can write to this socket can take full control of the host. In production, prefer the socket read-only (ro) or use a socket proxy such as docker-socket-proxy. Also apply a basic authentication middleware to the Traefik dashboard before exposing it publicly.
Nginx Proxy Manager: the no-config-file option
Nginx Proxy Manager (NPM) is the obvious choice for anyone uncomfortable with command-line configuration files. Its web interface allows you to create a proxy host in a few clicks: enter the domain name, the target container address, check 'Force SSL' and 'HTTP/2 Support', click 'Save' — that is it. NPM handles the rest, including the Let's Encrypt certificate request. However, NPM embeds a MariaDB database to store its configuration, which pushes its memory footprint to around 100 MB — nearly triple that of Caddy.
Troubleshooting: the most common errors
The vast majority of problems fall into four categories. First, Let's Encrypt rate limits: if you restart your stack several times in testing with the same domain, you can exhaust the limit of five certificates per domain over seven days. Solution: use the Let's Encrypt staging environment for your tests. Second, port conflicts: if port 80 or 443 is already occupied by the host's Apache or Nginx, the Docker proxy will not start. Identify the process with ss -tlnp | grep :80. Third, Docker network errors: verify both containers are on the same Docker network with docker network inspect proxy. Fourth, for Traefik, typos in labels are the most common cause of silently missing routes — enable debug logging with --log.level=DEBUG.
Summary: which proxy for which profile
- Solo developer, 2 to 5 apps — Caddy: minimal configuration, automatic TLS, up and running in under ten minutes with a fifteen-line Caddyfile
- DevOps team, microservices, CI/CD — Traefik: automatic discovery via labels, monitoring dashboard, perfect when the number of services varies dynamically
- Non-technical user or mixed team — Nginx Proxy Manager: intuitive graphical interface, no files to edit, built-in multi-user management
- VPS with limited RAM (512 MB to 1 GB) — Caddy or Traefik: avoid NPM which bundles MariaDB and consumes significantly more memory
- Progressive migration from Nginx — Caddy: its syntax can be learned in an hour, and it can temporarily coexist with Nginx on different ports
Conclusion: start with Caddy, evolve if needed
For the vast majority of self-hosters managing a few Docker applications on a VPS, Caddy is an excellent starting point: it is simple, lightweight, opinionated in the right way and handles TLS better than any alternative without extra configuration. If your infrastructure grows beyond five services with frequent automated deployments, Traefik becomes more appropriate thanks to its dynamic discovery. If you need to delegate domain management to non-technical people, Nginx Proxy Manager is the only truly accessible option.