Set Up Caddy as a Reverse Proxy for Docker Apps with Automatic HTTPS

Put a friendlier open-source front door in front of Docker apps so certificates and proxying are easier to run correctly the first time.

CaddyReverse proxyAutomatic HTTPS
Illustrated guide cover for Set Up Caddy as a Reverse Proxy for Docker Apps with Automatic HTTPS
Caddy • Reverse proxy • Automatic HTTPS
What you learn

How to run Caddy in Docker Compose, attach it to the right network, proxy one app by domain name, and verify that automatic HTTPS actually worked.

Best for

Single VPS stacks where you want a simpler public entry point than managing Nginx server blocks and Certbot separately.

Risk to watch

The common failure is blaming Caddy when DNS, port reachability, or backend networking is the real problem.

Before you begin

  • A domain or subdomain you control, already pointed at the VPS public IP.
  • Ports 80 and 443 reachable from the internet.
  • One Docker app already working on an internal container port.
  • Modern docker compose installed on the host.

Caddy is an open-source web server and reverse proxy with automatic HTTPS built in. Its quick-start docs emphasize an important baseline: if Caddy knows the hostname and the site is reachable, it will serve the proxy over HTTPS by default. That makes it a strong fit for smaller VPS stacks where you want a clean proxy layer without stitching together several moving parts first.

Warning: Automatic HTTPS is not magic. DNS and public reachability still have to be correct, and the backend container still has to work locally.

Step 1: Understand where Caddy fits

The safe pattern looks like this:

  • Caddy is the only container that publishes ports 80 and 443.
  • Your app container does not need to publish its internal port publicly.
  • Both containers share a Docker network so Caddy can reach the app by service name.

That architecture keeps the public surface small. The app stays private inside Docker networking, and Caddy becomes the single public web entry point.

Expected outcome: A reader should be able to look at the Compose file and immediately tell which service is public and which one stays private.

Step 2: Build the Compose layout

Create a project directory with a Caddy config folder and persistent storage directories:

mkdir -p ~/apps/caddy-proxy
cd ~/apps/caddy-proxy
mkdir -p caddy_data caddy_config

Then create a Compose file like this:

services:
  app:
    image: nginx:alpine
    expose:
      - "80"
    networks:
      - edge

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy_data:/data
      - ./caddy_config:/config
    depends_on:
      - app
    networks:
      - edge

networks:
  edge:

Two details matter here:

  • expose on the app advertises the internal port to other containers without publishing it to the world.
  • The Caddy data directory must persist, because that is where certificates and related state are stored.

If your app already lives in another Compose project, you can still attach both services to a shared external network. The principle stays the same: private backend, public proxy.

Step 3: Write a minimal Caddyfile

Create Caddyfile in the same directory:

app.example.com {
    reverse_proxy app:80
}

Caddy's reverse proxy quick start shows this exact strength: once it knows the hostname and the upstream, the config can stay small. Replace app.example.com with your real domain and app:80 with the service name and port your container listens on.

If your app needs special headers, websockets, or larger uploads, you can extend the config later. For the first deployment, keep it minimal and verify the basics first.

Check the config before starting the stack:

docker run --rm -v \"$PWD/Caddyfile:/etc/caddy/Caddyfile:ro\" caddy:2 caddy validate --config /etc/caddy/Caddyfile

Step 4: Start the stack and test in the right order

Bring the services up:

docker compose up -d

Then test each layer in order:

  1. Does the backend app work inside the network?
  2. Does Caddy start cleanly?
  3. Does the domain resolve to the VPS?
  4. Can you reach the site over HTTPS in a browser or with curl?
docker compose ps
docker compose logs caddy --tail=100
docker compose exec caddy wget -qO- http://app:80 | head
curl -I https://app.example.com

If everything is correct, you should see a working HTTPS response without having to run Certbot separately. Caddy's docs make clear that it serves HTTPS automatically when it knows the hostname and the environment allows certificate issuance.

Once the proxy works, keep the app private. Do not add a published app port out of convenience unless you truly intend that app to be public outside Caddy.

Rollback and recovery notes

The clean rollback is simply to restore the previous public path and remove the new Caddy entry point from the traffic flow. If you are replacing an older proxy, do not destroy that working config until the Caddy path is fully verified.

Useful recovery checks:

docker compose logs caddy --tail=200
docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile
docker compose down

If HTTPS issuance fails, do not start by deleting certificate data. First confirm DNS points at the right host, ports 80 and 443 are reachable, and the domain is not still routed somewhere else.

Troubleshooting common Caddy proxy problems

The browser shows a certificate or issuance error.
Check DNS, port reachability, and whether another service is already bound to 80 or 443. Automatic HTTPS depends on the hostname resolving to this VPS and being reachable.

Caddy starts, but returns 502.
That usually means the backend app is not reachable at the service name or port you configured. Test connectivity from inside the Caddy container.

The app still appears on a raw public port.
Remove the app's published ports: entry. If Caddy is the public edge, the backend does not need direct public exposure.

The config is valid, but the wrong app loads.
Confirm the domain block and upstream target in the Caddyfile. Short configs are easier to review, which is one of the reasons Caddy is attractive here.

Expected outcome: One public proxy, one private backend, a persistent certificate store, and a smaller amount of reverse-proxy ceremony than a manual Nginx plus Certbot setup.

What to do next

Once a public app is working cleanly through Caddy, the next useful hardening move is separating public traffic from private admin access. Continue with Set Up WireGuard for Private VPS Admin Access.