Modern web apps need a few things on a VPS: HTTPS with TLS certificates, a reverse proxy for APIs, static file hosting for frontends, and flexible domain/subdomain routing. Traditionally, you might use Nginx plus Certbot, but that requires manual cert renewal and complex configs. Caddy is different — it automates HTTPS and simplifies configuration. In practice, combining Caddy with Docker gives a clean, robust deployment. This guide covers real-world patterns: hosting a frontend SPA, proxying APIs, handling subdomains, and using Caddy as the gateway in a full Docker stack.
Why Use Caddy Instead of Nginx?
Here are some key advantages of Caddy for most Docker-backed deployments:
Automatic HTTPS
By default, Caddy provisions and renews TLS certificates for your domains, and redirects HTTP to HTTPS. For example, simply writing example.com { reverse_proxy localhost:3000 } will auto-enable HTTPS (with certificates from Let's Encrypt). You don't need a separate certbot or cron jobs.
Simple Configuration
Caddyfiles use a clean, readable syntax. Site blocks and directives like reverse_proxy, file_server, and try_files make common tasks straightforward (contrast this with Nginx's more verbose config). This improves maintainability.
Docker Friendly
The official Caddy Docker image has no external dependencies and is lightweight. It supports named volumes for /data (for certs and state) and /config, which you should persist across restarts. Running Caddy in Docker is as simple as docker pull caddy and mounting your Caddyfile and site content.
Basic Caddy with Docker Setup
A minimal Docker Compose setup with Caddy looks like this:
services:
caddy:
image: caddy:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./site:/srv
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
- The
/etc/caddy/Caddyfilevolume is your server configuration. -
./site(mounted to/srv) holds your static files (e.g., React/Vue build output). -
/datais where Caddy stores TLS certs, keys, OCSP staples, etc. Persist/data– do not treat it as ephemeral. -
/configholds Caddy's JSON runtime config.
Persisting /data and /config is critical: without it, Caddy would re-issue certificates on every restart (and risk hitting Let's Encrypt rate limits). The Docker Hub docs emphasize that the data directory "must not be treated as a cache" and should persist across container restarts. Using named volumes (caddy_data, caddy_config) handles this automatically.
What is a Caddyfile?
The Caddyfile is the core configuration file of Caddy. It defines how your server behaves — similar to what multiple Nginx config blocks would do, but in a much simpler format. With it, you can:
- Configure HTTPS automatically
- Serve static sites
- Proxy APIs to backend services
- Handle multiple domains or subdomains
In short: Docker runs Caddy, but the Caddyfile defines what Caddy does.
Example Caddyfile (Basic Static Site)
example.com {
root * /srv
file_server
}
What this does:
-
example.com→ domain Caddy listens to -
root * /srv→ points to your static files (from./site) -
file_server→ enables static file hosting - HTTPS is automatically handled by Caddy (no extra config needed)
Why this setup matters
With just Docker Compose + a Caddyfile:
- You get automatic HTTPS
- No Certbot or manual SSL setup
- Clean separation between infra (Docker) and routing (Caddyfile)
- Production-ready foundation for all later patterns
This is the base layer that everything else in your deployment builds on.
Production Patterns with Caddy + Docker
Once your basic setup is running, the real power of Caddy comes from how you structure it in production. The following patterns cover common, real-world scenarios — serving SPAs, proxying APIs, handling subdomains, and managing multiple services behind a single gateway. You can mix and match these depending on your architecture, whether you're deploying a simple app or a full microservices stack.
Pattern 1: Hosting a Frontend SPA with Caddy
Most modern frontends (React, Vue, etc.) are single-page applications (SPAs). After building (npm run build), you serve the static files. Caddy can do this simply:
example.com {
root * /srv
try_files {path} /index.html
file_server
encode gzip
header {
Cache-Control "public, max-age=31536000, immutable"
}
}
-
root * /srvtells Caddy where your static files live (the./sitevolume). -
SPA fallback:
try_files {path} /index.htmlmeans if the requested file doesn't exist, serveindex.htmlinstead. This is crucial for client-side routing in SPAs. Without this, refreshing at a nested route (e.g./dashboard) would return 404. -
file_serverenables serving the files. - We also enable gzip compression (
encode gzip) and set aggressive caching headers for static assets.
As the Caddy docs explain, this pattern "tries" the file on disk and falls back to the SPA's index.html. The official examples recommend wrapping SPA routing and headers appropriately. For instance, if you have hashed asset filenames, you might add a Cache-Control header so browsers know they can cache assets long-term.
Pattern 2: Reverse Proxy API with Subdomain
Often, your API is running on a different port or container. You can use Caddy to proxy it under its own subdomain. For example:
api.example.com {
reverse_proxy backend:3000
}
Here, backend would be the Docker service name or IP. Caddy will automatically obtain a TLS certificate for api.example.com and proxy requests to the API container. This keeps clean URLs (no port in the URL) and offloads HTTPS to Caddy. The same could be done to another server: e.g. reverse_proxy 12.34.56.78:8000. Caddy handles the HTTPS and routing, so your frontend code can just call https://api.example.com/... without worrying about certs or ports.
Pattern 3: Using Caddy as the Gateway for a Full Docker Stack
In larger applications, you'll have multiple services (e.g., frontend, backend, database, Redis, admin UIs, etc.). In such cases, Caddy serves as the single entry point to the stack. All public traffic hits Caddy (on ports 80/443), and Caddy internally routes to the right containers. Internally, services communicate via Docker's network (by service name), while only Caddy has public ports.
Illustration: A typical Docker deployment with Caddy at the front. All traffic goes through Caddy (with ports 80/443 open) which proxies to internal services on the Docker network.
A sample docker-compose.yml might look like:
services:
caddy:
image: caddy:latest
restart: unless-stopped
cap_add:
- NET_ADMIN # for ACME/DNS or QUIC (optional)
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
api:
condition: service_healthy
api:
image: my-backend-image
expose:
- "3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 5
pgadmin:
image: dpage/pgadmin4
expose:
- "80"
depends_on:
- api
# (other services like db, redis, etc.)
volumes:
caddy_data:
caddy_config:
Caddy service: Only Caddy exposes ports 80 and 443. The cap_add NET_ADMIN is often added to allow HTTP/3 (QUIC) support, but it is optional.
Volumes: We mount our Caddyfile and the named volumes for /data and /config to persist certs and config.
depends_on & healthcheck: Here we demonstrate using Docker healthchecks. Caddy's container is set to wait (depends_on) until the api service passes its health check. This prevents Caddy from starting up too early and proxying to a not-yet-ready backend (which would cause 502 errors). Using health checks makes the startup more robust.
Pattern 4: Proxying with Real IP Headers
In production, you often want your backend to see the real client IP (for logging, rate-limiting, etc.). With Caddy, you can configure headers to pass through the client info. For example:
api.example.com {
reverse_proxy api:3000 {
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up Host {host}
}
}
This ensures your backend (e.g., an Express or NestJS app) can use X-Real-IP or X-Forwarded-For to know the original client IP.
Pattern 5: Handling WebSockets (Socket.IO, etc.)
If your app uses WebSockets (e.g., chat, live updates, Socket.IO), Caddy's reverse_proxy handles WebSocket upgrades automatically. You don't need special config for basic cases. You might route a specific path for sockets, e.g.:
example.com {
reverse_proxy /socket.io* api:3000
@api not path /socket.io*
handle @api {
reverse_proxy api:3000
}
}
Here we proxy /socket.io (WebSocket) and other routes to the same API. In practice, you can often let a single reverse_proxy handle both HTTP and WS if they share a hostname, but defining matchers (like @socket) can avoid conflicts if you also host other services on the same domain.
Pattern 6: Exposing Admin Tools Safely
You may have internal tools (e.g., pgAdmin, Adminer, or Prometheus) that you want accessible via HTTP but don't want extra public ports. Caddy can route these under paths or subdomains. For example:
example.com {
handle /pgadmin* {
reverse_proxy pgadmin:80
}
handle {
reverse_proxy api:3000
}
}
This makes https://example.com/pgadmin point to your pgAdmin container (which listens on port 80 internally). The advantage is that access is over HTTPS with your main cert, and you can later add authentication or IP filtering if needed, without exposing another port or domain.
Pattern 7: Domain Redirects (Canonical Host)
It's good practice to pick one primary domain and redirect others to it (for SEO and consistency). Caddy's redir directive makes this easy. For example:
example.net, www.example.net, example.org {
redir https://example.com{uri} 301
}
This matches any request to those secondary domains and permanently redirects it to https://example.com (preserving the path via {uri}). Caddy docs show similar examples. By issuing a 301 redirect, search engines will eventually consolidate authority on the main domain.
Pattern 8: Handling Large File Uploads
By default, Caddy may reject very large request bodies. If your API needs to accept big uploads, use the request_body directive to increase the limit. For example:
api.example.com {
handle {
request_body {
max_size 500MB
}
reverse_proxy api:3000
}
}
This allows uploads up to 500 MB (otherwise Caddy would return HTTP 413 if exceeded). The docs show how max_size works – e.g., to set a 10MB limit. Adjust as needed for your application.
CI/CD-Friendly Static Deployments
One nice perk: Caddy will serve new static files as soon as they appear in the mounted directory. No container restart needed. For example, in a CI/CD pipeline, you could build your frontend, scp the files into the /srv directory on the VPS, and Caddy will automatically serve the updates. This means zero downtime for static site changes and no need to bounce the Caddy container for every release (unlike many Docker-based setups).
Production Tips and Common Pitfalls
Persist important volumes
As mentioned, always use named volumes for /data and /config (e.g. caddy_data and caddy_config). Losing these means losing your certificates and config state.
Open only needed ports
On your server's firewall (or cloud security group), only allow 80 and 443 through. All other services (DB, Redis, etc.) should NOT be publicly accessible. They talk to each other over the Docker network.
Validate your Caddyfile
Before reloading in production, run caddy validate --config /etc/caddy/Caddyfile (inside the container). This catches syntax errors ahead of time.
Zero-downtime reloads
Caddy can reload config without dropping connections. Use caddy reload (for Docker: docker exec -w /etc/caddy <container> caddy reload). The Docker Hub docs even suggest this pattern for graceful reloads.
Healthchecks
As shown above, use Docker healthchecks for backend services and depends_on with condition: service_healthy so that Caddy waits for them.
Logging
If things aren't working, check docker logs caddy and docker logs <service> for clues. Common issues include DNS typos (make sure service names match your Compose), wrong port numbers, or forgetting to expose a port on a container (use expose or ports appropriately).
Troubleshooting
If your site isn't reachable, verify DNS (A records for your domain pointing to the server) and that ports 80/443 are open. The official Caddy docs have good troubleshooting tips.
Final Thoughts
Using Caddy as your Docker gateway lets you offload many tedious tasks (TLS, redirects, proxying) to a simple configuration. Whether you're doing a simple static site or a complex microservice architecture, the same patterns scale up. The Caddyfile syntax is easy to understand, and the built-in automation (automatic HTTPS, reloads, etc.) reduces maintenance overhead.
Overall, for Docker-based deployments on a VPS, Caddy often means fewer moving parts and less "boring work" (no cron jobs for certs!). Once you've set up these patterns, you can add extra features like encryption or auth without re-architecting. In short: If you're already comfortable with Docker, adding Caddy as your front door is usually one of the cleanest, quickest ways to get secure, reliable hosting in production.
Top comments (0)