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 composea domain or subdomain you can point to the server
ports
80and443reachable from the interneta user account with SSH access
If you still need the Docker basics first, start here:
How to Install Docker on Ubuntu and Run Your First Container
Docker Compose for Self-Hosted Apps: A Beginner-Friendly Guide
Important: Automatic HTTPS depends on real network conditions, not hope and positive vibes. Your DNS records must point to the server, and ports
80and443must 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
80and443are reachableCaddy 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
80and443a 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-userwith your Linux usernameyour-server-ipwith 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:
/datafor certificates and TLS state/configfor 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
80or443
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
80is blockedport
443is blockedanother 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:
the app service in
compose.yamlthe 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
80and443are open externallyconfirm 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.yamlyour
conf/Caddyfilethe Docker volumes used for
caddy_dataandcaddy_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.
