Updated
Deploying Web Apps with Caddy: Docker, HTTPS and Reverse Proxy

Updated

Caddy can serve a built web application and forward API requests from the same address. This walkthrough puts both paths in one small Docker Compose project so you can see which service handled a request before adapting it to a real application.
The local example uses Caddy 2.11.9 and HTTP on your computer's loopback interface. The final section explains the changes for a public HTTPS domain. The demo backend returns fixed text; it is a routing fixture you can replace with your own API.
With Docker and the Compose plugin installed, create a directory containing compose.yaml, a conf directory and a site directory. Save this as compose.yaml:
services:
caddy:
image: caddy:2.11.9-alpine
ports:
- "127.0.0.1:8088:80"
volumes:
- ./conf:/etc/caddy:ro
- ./site:/srv:ro
- caddy_data:/data
- caddy_config:/config
demo:
image: caddy:2.11.9-alpine
command: ["caddy", "respond", "--listen", ":9000", "--body", "Demo API is reachable"]
volumes:
caddy_data:
caddy_config:
Only Caddy's HTTP listener is published to the host. The demo service is reachable inside the project's network. Docker Compose makes it discoverable as demo, so the proxy can use demo:9000 without a hard-coded container IP. See Docker's Compose networking guide.
Create conf/Caddyfile:
:80 {
encode zstd gzip
handle /api/* {
reverse_proxy demo:9000
}
handle /assets/* {
root * /srv
file_server
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}
Create site/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Caddy routing demo</title></head>
<body><h1>The web application is reachable</h1></body>
</html>
For a real frontend, place the contents of its build output in site. Keep source files, credentials and private configuration outside that directory.
Run these commands from the project directory:
docker compose config --quiet
docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose up -d
curl -i http://127.0.0.1:8088/
curl -i http://127.0.0.1:8088/dashboard
curl -i http://127.0.0.1:8088/api/health
curl -i http://127.0.0.1:8088/assets/missing.js
The first two requests should return the HTML document with status 200. The API request should return “Demo API is reachable”. The missing asset should return 404. If port 8088 is already occupied, change the host port in Compose and use it in the checks.
These checks serve different purposes. A homepage response proves static files are available. The dashboard request tests the fallback needed by a client-side router. The API response establishes that the reverse proxy reached another service. The missing-file check catches a common mistake: returning an HTML document when a browser requested JavaScript.
The API handler forwards the request with its /api/ prefix intact. Your real backend must accept that path, or you need an explicit path-rewriting decision. The separate asset handler lets missing assets remain missing instead of falling through to the application shell. These examples follow Caddy's documented routing patterns.
The final handler is a broad SPA fallback. It also returns status 200 for unknown page paths. That may be appropriate for a client router, but it does not create genuine HTTP 404 responses or render page-specific content for search engines. For a public content site, decide how prerendered pages and unknown routes will be served.
Replace demo with your application service when you move beyond the fixture. Inside its container, the application should listen on an interface reachable from the Compose network. Pointing Caddy at localhost would point back at the Caddy container itself. Caddy's reverse-proxy quick-start covers the basic upstream configuration.
After editing the Caddyfile, validate it and ask the running process to reload it:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose logs --tail=50 caddy
The official image runs Caddy in the foreground. You do not need to start a second background process inside it. Configuration validation and reload have distinct jobs; the command-line reference explains both.
For a useful failure check, stop the demo service with docker compose stop demo. A request to /api/health should fail with 502 while the static homepage remains available. Start the service again with docker compose start demo and repeat the API request. This distinguishes an upstream outage from a frontend routing problem.
On a public host, replace the opening :80 with your hostname, such as app.example.com. Replace the local port mapping with public TCP ports 80 and 443. Configure DNS and the host's network so those ports reach Caddy. UDP 443 is optional if you want to serve HTTP/3.
Caddy can then obtain and renew a public certificate when its automatic HTTPS requirements are satisfied. A domain name in a file alone does not establish that DNS, firewall rules or an intervening proxy are correct. Check the public HTTP redirect and the certificate presented over HTTPS after deployment.
Keep the /data volume: it contains certificate state and private keys. Protect and back it up appropriately. Do not publish Caddy's admin endpoint as an internet-facing port. The official running Caddy guide covers service operation and persistent Docker storage.
The local fixture was checked with Caddy 2.11.9: configuration validation, static content, the dashboard fallback, API proxying, a missing asset, configuration reload and backend outage/recovery. Public DNS, certificate issuance and your application's own authentication are separate deployment checks.
When you finish experimenting, docker compose down removes the project's containers and network while retaining the named volumes. The small example is a starting point for routing, not a complete production operating plan.
If you need help taking an application from a local build to a maintainable service, see my software development and integration work or get in touch.