DevPlainly
Self-Hosting

How to Set Up Caddy as a Reverse Proxy (With Automatic HTTPS)

Put Caddy in front of your self-hosted apps for free automatic HTTPS. Caddyfile examples for one app, many apps, static sites, auth and Docker Compose.

· 5 min read

Contents
  1. Why use a reverse proxy?
  2. Prerequisites
  3. Step 1: The Compose file
  4. Step 2: Your first Caddyfile
  5. Common Caddyfile recipes
  6. Several apps on subdomains
  7. Redirect www to the bare domain
  8. Serve a static website
  9. Path-based routing (frontend and API on one domain)
  10. Password-protect an app
  11. Proxy to an app running outside Docker
  12. Security headers
  13. Reloading after changes
  14. Troubleshooting
  15. Caddy vs nginx vs Traefik
  16. FAQ
  17. Is Caddy free?
  18. Can I use Caddy without a domain?
  19. Does Caddy work behind Cloudflare?
  20. Further reading

A reverse proxy sits in front of your apps, receives every request on ports 80 and 443, and forwards it to the right service. Caddy is the easiest one to run because it gets and renews HTTPS certificates automatically — no Certbot, no cron jobs, no manual renewals.

This guide shows how to run Caddy with Docker Compose and gives copy-paste Caddyfile examples for the most common setups.

Why use a reverse proxy?

Without one, each app listens on its own port (:3001, :8080, :9000…), traffic is unencrypted, and every port is exposed to the internet. With a reverse proxy:

  • Each app gets a clean URL like status.example.com instead of 203.0.113.10:3001.
  • All traffic is encrypted with HTTPS.
  • Only ports 80 and 443 need to be open; the apps stay private.
  • You get one place for logs, compression, headers and access control.
Visitorsbrowsers, appsHTTPS :443Your serverCaddyauto HTTPS certificatesprivate Docker networkstatus.example.com → :3001notes.example.com → :8080api.example.com → :8000
Caddy receives all traffic on ports 80/443, handles HTTPS and forwards each hostname to the right container.

Prerequisites

  1. A Linux server with Docker and Docker Compose. See Docker Compose for beginners.
  2. A domain name, with a DNS A record pointing each hostname (for example app.example.com) to your server's public IP. Check with nslookup app.example.com.
  3. Ports 80 and 443 open in your firewall. With ufw: sudo ufw allow 80,443/tcp. Caddy needs port 80 to complete the certificate challenge.

Step 1: The Compose file

Create a folder for Caddy and your apps:

mkdir -p ~/stack && cd ~/stack

compose.yaml:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

  whoami:
    image: traefik/whoami   # tiny test app that echoes request details
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Two important details:

  • caddy_data must be a persistent volume. It stores your certificates. If you lose it, Caddy requests new ones on every restart and can hit the certificate authority's rate limits.
  • The whoami service has no ports: section. Only Caddy can reach it, through the internal Docker network, by its service name.

Step 2: Your first Caddyfile

Create Caddyfile next to compose.yaml:

app.example.com {
    reverse_proxy whoami:80
}

That is the entire configuration. Start everything:

docker compose up -d
docker compose logs -f caddy

Within a few seconds the logs should show Caddy obtaining a certificate. Open https://app.example.com and you will see the whoami output over HTTPS. Requests to http:// are redirected to https:// automatically.

Common Caddyfile recipes

Several apps on subdomains

status.example.com {
    reverse_proxy uptime-kuma:3001
}

notes.example.com {
    reverse_proxy notes-app:8080
}

api.example.com {
    reverse_proxy api:8000
}

Each block gets its own certificate. Add each service to the same compose.yaml (or the same Docker network) so Caddy can reach it by name.

Redirect www to the bare domain

www.example.com {
    redir https://example.com{uri} permanent
}

example.com {
    reverse_proxy web:3000
}

Serve a static website

example.com {
    root * /srv/site
    encode zstd gzip
    file_server
}

Mount the site folder into the container with - ./site:/srv/site:ro.

Path-based routing (frontend and API on one domain)

example.com {
    handle /api/* {
        reverse_proxy api:8000
    }
    handle {
        reverse_proxy web:3000
    }
}

handle blocks are mutually exclusive and checked in order of specificity, so /api/... goes to the API and everything else to the frontend. Use handle_path instead of handle if the backend expects the /api prefix to be stripped.

Password-protect an app

Generate a password hash:

docker compose exec caddy caddy hash-password --plaintext 'choose-a-strong-password'

Then use it:

admin.example.com {
    basic_auth {
        admin $2a$14$REPLACE_WITH_THE_HASH_YOU_GENERATED
    }
    reverse_proxy admin-panel:8080
}

Older Caddy versions spell the directive basicauth; both work in current releases.

Proxy to an app running outside Docker

If an app runs directly on the host (for example on port 5000), use the host gateway:

  caddy:
    extra_hosts:
      - "host.docker.internal:host-gateway"
legacy.example.com {
    reverse_proxy host.docker.internal:5000
}

Security headers

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    reverse_proxy web:3000
}

Reloading after changes

After editing the Caddyfile, reload without downtime:

docker compose exec -w /etc/caddy caddy caddy reload

Validate first if you are unsure:

docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

Format the file neatly with caddy fmt --overwrite run the same way.

Troubleshooting

SymptomLikely causeFix
Certificate errors in logsDNS not pointing to the server yetCheck the A record with nslookup, wait for DNS to update
timeout during connect in logsPort 80 or 443 blockedOpen them in ufw and in your provider's cloud firewall
502 Bad GatewayCaddy cannot reach the appUse the service name and the app's internal port; make sure both share a network
Redirect loopApp also forces HTTPS, or a CDN proxy in front uses plain HTTPLet Caddy handle HTTPS; set the CDN to full/strict TLS
App generates http:// linksApp does not trust proxy headersConfigure the app to trust X-Forwarded-Proto from the proxy

Caddy vs nginx vs Traefik

CaddynginxTraefik
Automatic HTTPSBuilt inNeeds Certbot or similarBuilt in
Config styleShort CaddyfileVerbose config blocksLabels on containers or YAML
Learning curveLowestMediumMedium
Best forSmall to medium self-hosting setupsHigh-traffic sites, fine-grained tuningDynamic container setups

For most self-hosters with a handful of apps, Caddy's tiny config and automatic certificates make it the simplest choice.

FAQ

Is Caddy free?

Yes. Caddy is open source under the Apache 2.0 license, and the certificates it obtains from Let's Encrypt or ZeroSSL are free.

Can I use Caddy without a domain?

For local or LAN use, yes — Caddy can issue certificates from its own local certificate authority for names like app.localhost. Browsers will only trust them on machines where that local CA is installed. For public HTTPS you need a real domain.

Does Caddy work behind Cloudflare?

Yes. If Cloudflare's proxy is enabled, set its SSL mode to Full (strict). Caddy can still obtain certificates, or you can use a DNS challenge plugin.

Further reading

Keep reading