How to Use Caddy as a Reverse Proxy for Self-Hosted Apps

Caddy is a good fit when you want the benefits of a reverse proxy without turning the setup into a side quest about hand-tuning a giant web server config. For many self-hosted projects, it gives you the shortest path from “my app works on port 3000” to “my app opens on a real domain over HTTPS” with less fuss than older proxy workflows.

In this guide, you will run Caddy with Docker Compose, point it at a sample app, connect your domain, and let Caddy handle HTTPS automatically. Once the stack is working, you can reuse the same pattern for other self-hosted apps that need a cleaner public URL.

Why use Caddy for self-hosting?

A reverse proxy sits in front of your app and accepts web traffic for it. Instead of asking people to remember a raw port like http://your-server-ip:3000, you can give them a cleaner address such as https://app.yourdomain.com.

Caddy is popular for this because it keeps the basics simple:

  • the config is small and readable

  • HTTPS is automatic when your domain points to the server

  • HTTP requests are redirected to HTTPS automatically

  • the same pattern works for many different apps later

  • it is easier to reason about than a huge hand-built proxy config when you are still learning

The official Caddy docs describe it as a production-ready reverse proxy and emphasize that it serves HTTPS automatically by default when it knows the hostname it should serve.

When Caddy is a good choice

Caddy makes a lot of sense when:

  • you want a lightweight reverse proxy without a large admin UI

  • you are comfortable editing one small config file

  • you want automatic HTTPS with minimal ceremony

  • you expect to run several self-hosted apps over time

If you would rather manage proxy hosts through a browser dashboard, Nginx Proxy Manager may feel friendlier instead:

What you need before you start

Before following along, have these ready:

  • a Linux VPS or home server with a public IP

  • Docker installed and working

  • Docker Compose available as docker compose

  • a domain or subdomain you can point to the server

  • ports 80 and 443 reachable from the internet

  • a user account with SSH access

If you still need the Docker basics first, start here:

Important: Automatic HTTPS depends on real network conditions, not hope and positive vibes. Your DNS records must point to the server, and ports 80 and 443 must be open to Caddy.

How Caddy handles HTTPS automatically

This is the feature that makes Caddy stand out.

According to Caddy's automatic HTTPS docs, Caddy will automatically obtain and renew certificates when:

  • your config includes a real hostname

  • the domain points at your server

  • ports 80 and 443 are reachable

  • Caddy can persist its data directory

That persistent data matters because Caddy stores certificates and related TLS data there. The official Docker image docs specifically warn that /data should not be treated like disposable cache space.

Before you start

All commands in this tutorial run on the server over SSH. If your app is hosted on a VPS, connect to the VPS first and do the setup there.

For this walkthrough, I will proxy a tiny demo container first. That keeps the initial test simple:

  • Caddy will listen on ports 80 and 443

  • a sample app will listen only inside the Docker network

  • Caddy will forward traffic to that internal app

  • later you can replace the demo app with a real service such as Uptime Kuma, Vaultwarden, or Nextcloud

Step 1: Connect to your server

Use SSH from your local machine.

ssh your-user@your-server-ip

Replace:

  • your-user with your Linux username

  • your-server-ip with your server's public IP address

If SSH asks whether you trust the host fingerprint on the first connection, that is normal.

Step 2: Confirm Docker Compose is available

Make sure the Compose plugin works before you build anything on top of it.

docker compose version

If Docker prints a version, you are ready to continue. If it says the command is missing, fix Docker first before you keep going.

Step 3: Create a folder for the Caddy stack

Keeping each self-hosted app in its own folder makes updates, backups, and troubleshooting much less annoying later.

sudo mkdir -p /opt/caddy-reverse-proxy
sudo chown "$USER":"$USER" /opt/caddy-reverse-proxy
cd /opt/caddy-reverse-proxy

Step 4: Create folders for the Caddy config and persistent data

The official Caddy Docker image expects a config location plus persistent data and config storage.

mkdir -p conf site

The Docker image documentation highlights two important writable paths:

  • /data for certificates and TLS state

  • /config for Caddy configuration state

I will mount both as named volumes in Compose so that restarts and upgrades do not wipe them.

Step 5: Create the Caddyfile

Caddy's reverse proxy quick-start shows that the heart of the config is the reverse_proxy directive. For a real HTTPS setup, the site address should be your domain name.

cat > conf/Caddyfile <<'EOF'
app.yourdomain.com {
    encode zstd gzip
    reverse_proxy app:80
}
EOF

Replace app.yourdomain.com with the real domain or subdomain you want to use.

What this Caddyfile does

app.yourdomain.com

This tells Caddy which hostname it should serve. When that hostname points to your server and ports 80 and 443 are open, Caddy will try to obtain a certificate automatically.

encode zstd gzip

This enables common response compression so proxied content is sent more efficiently when supported.

reverse_proxy app:80

This forwards incoming web requests to the container named app on port 80 inside the Docker network.

Step 6: Create the Docker Compose file

Now create the stack itself. This uses the official Caddy image pattern with persistent /data and /config volumes, plus a tiny demo app so you can verify the proxy immediately.

cat > compose.yaml <<'EOF'
services:
  app:
    image: nginxdemos/hello:plain-text
    restart: unless-stopped

  caddy:
    image: caddy:2
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

volumes:
  caddy_data:
  caddy_config:
EOF

What each important part does

image: caddy:2

This pulls the official Caddy 2 Docker image. Using the major-version tag keeps the example readable while still following the current Caddy 2 line.

cap_add: NET_ADMIN

The official Docker image docs include this in their Compose example. It is mainly useful for networking behavior such as HTTP/3-related UDP buffer tuning. If your provider refuses this capability, remove it and try again.

Port 80:80

Caddy needs port 80 for HTTP traffic and for common ACME validation flows.

Port 443:443

This is the main HTTPS port.

Port 443:443/udp

This supports HTTP/3 over UDP. It is a good default even if some readers will not notice it directly.

./conf:/etc/caddy

This gives the container access to your Caddyfile. The official Docker docs recommend mounting the config directory rather than mounting only one file.

caddy_data:/data

This persists certificates and other important TLS state. Do not treat it like throwaway scratch storage.

caddy_config:/config

This persists additional Caddy configuration state that is useful to keep across restarts.

Step 7: Start the stack

Bring the containers up.

docker compose up -d

The first run may take a minute because Docker needs to pull the images.

Step 8: Check whether both containers are running

docker compose ps

You want to see both app and caddy in a running state.

If the Caddy container is restarting or exited, check the logs next.

docker compose logs caddy --tail=100

Step 9: Point your domain at the server

Before HTTPS can work, your domain needs to resolve to this machine. Create or update an A record for your chosen hostname so it points to the server's public IPv4 address. If you use IPv6, also add the matching AAAA record.

For example, if your hostname is app.yourdomain.com, your DNS provider should point that name to your server.

What to check if DNS feels cursed

  • the hostname in DNS exactly matches the hostname in your Caddyfile

  • the record points to the correct public IP

  • old DNS values are not still cached somewhere

  • no other reverse proxy or web server is already using ports 80 or 443

Step 10: Open the site in your browser

Once DNS has updated enough for the hostname to resolve, open:

https://app.yourdomain.com

Replace that with your real hostname.

If everything is working, you should reach the demo app through Caddy over HTTPS. The browser should show a valid certificate rather than a scary warning page.

Step 11: Verify that Caddy obtained a certificate

You can confirm what Caddy is doing from the logs.

docker compose logs caddy --tail=100

When the domain and ports are correct, the logs should show certificate activity rather than endless failure loops.

If you see messages about failed validation, the problem is usually one of these:

  • DNS is still pointing somewhere else

  • port 80 is blocked

  • port 443 is blocked

  • another service is already bound to those ports

How to replace the demo app with a real self-hosted app later

Once this works, the general pattern stays the same. You usually only change two things:

  1. the app service in compose.yaml

  2. the upstream target in reverse_proxy

For example, if your real app listens on port 3000, your Caddyfile might look like this:

app.yourdomain.com {
    encode zstd gzip
    reverse_proxy app:3000
}

If the app runs in the same Compose project, using the service name as the upstream is usually the cleanest option.

How to reload Caddy after config changes

The Caddy Docker docs recommend reloading the config in the running container instead of restarting the whole stack every time.

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

That is the command to run after you edit the Caddyfile.

Common problems and quick fixes

The browser says the site cannot be reached

Check whether the hostname actually resolves to your server. If DNS is wrong, Caddy cannot fix that for you.

Caddy keeps failing to get a certificate

Start with the basics:

  • confirm the hostname in the Caddyfile is correct

  • confirm the DNS record points to the server

  • confirm ports 80 and 443 are open externally

  • confirm no other service is already using those ports

Another web server is already using ports 80 or 443

Run this to see what is listening:

sudo ss -tulpn | grep -E ':80|:443'

If Nginx, Apache, or another proxy is already bound there, stop that service or move Caddy to the correct host before trying again.

The container starts but the site still fails

Read the logs instead of guessing.

docker compose logs caddy --tail=200

That usually tells you faster whether the problem is DNS, permissions, or a bad Caddyfile.

How to update Caddy later

A safe routine update looks like this:

docker compose pull
docker compose up -d
docker compose ps

This pulls newer images, recreates containers if needed, and lets you confirm the stack came back cleanly.

How to back it up

For this stack, the most important data is the Caddy state stored in the persistent volumes, especially the certificate data in /data.

At minimum, document or back up:

  • your compose.yaml

  • your conf/Caddyfile

  • the Docker volumes used for caddy_data and caddy_config

If you skip the volume backup, rebuilding later may still work, but Caddy will need to re-establish certificate state.

How to remove the stack

If you no longer need this test setup, stop and remove it with:

docker compose down

If you also want to remove the named volumes, use:

docker compose down -v

Be careful with -v because that also deletes the stored Caddy data and config for this stack.

Where to go next

Once you have Caddy working as a reverse proxy, you have a reusable front door for other self-hosted apps. A sensible next step is to put a real service behind it, such as:

  • Uptime Kuma

  • Vaultwarden

  • Paperless-ngx

  • Nextcloud

You can also compare whether you prefer this file-based approach or a dashboard-based one like Nginx Proxy Manager.

You’re done

You now have a working Caddy reverse proxy stack running in Docker Compose, a persistent place for certificate data, and a simple pattern you can reuse for future self-hosted apps. The next time an app comes with “open port 3000 and hope for the best” instructions, you can give it a proper domain and HTTPS instead.

Frequently Asked Questions

Do I need Nginx experience before using Caddy?
No. Caddy is beginner-friendly because a basic reverse proxy can be described in just a few lines, and it handles HTTPS automatically when your domain and ports are set up correctly.
Why does the Caddy setup expose 443/udp as well as 443/tcp?
Caddy supports HTTP/3, which uses UDP on port 443. Mapping 443/udp is not strictly required for every setup, but it is a sensible default when you want modern browser support.
Can I use Caddy without Docker Compose?
Yes. You can install Caddy directly on Linux and run it as a service, but Docker Compose is a nice fit for self-hosted stacks because the config, volumes, and update workflow stay together in one app folder.

Related articles

🔁
Reverse Proxies
How to Install Nginx Proxy Manager with Docker Compose
Install Nginx Proxy Manager with Docker Compose, open the admin dashboard, and set up the foundation for clean domains and HTTPS on your self-hosted apps.
📊
Monitoring
How to Install Uptime Kuma with Docker Compose
Install Uptime Kuma with Docker Compose on a Linux server, create your first admin account, and start monitoring websites or services from a simple web dashboard.
🐳
Docker & Containers
Docker Compose for Self-Hosted Apps: A Beginner-Friendly Guide
Learn how to use Docker Compose for self-hosted apps, including folder layout, compose files, environment variables, volumes, safe restarts, and basic update habits.
🎬
Media Servers
How to Install Immich with Docker Compose
Install Immich with Docker Compose on a Linux server, set up storage paths safely, start the stack, and finish the first web-based admin setup.