If you want to understand why so many people call Caddy the easiest web server and reverse proxy, the best way is to go in order: install it, serve a simple site, then put an app behind it. That is exactly the flow this write-up follows.
Installation#
The official installation instructions live here:
Caddy supports multiple install paths, including native packages, Docker, and static binaries. For a quick Linux setup, Ubuntu is one of the easiest ways to start because Caddy has an official package.
Quick Ubuntu example#
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
On Ubuntu, the package install also sets up the caddy systemd service. That makes it a nice fit for a simple VPS or homelab VM.
Useful next step after install:
sudo systemctl status caddy
Configure the web server#
Before touching reverse proxying, start with the simplest possible win: serve a static page.
Upload your demo website to /var/www/html.
sudo mkdir -p /var/www/html
Then replace the default config in /etc/caddy/Caddyfile with a minimal site:
demo.domain.tld {
root * /var/www/html
file_server
}
Format and reload the config:
sudo systemctl reload caddy
Configure the reverse proxy#
Once the static site works, the next step is to proxy an app through Caddy.
For a more practical backend example, start Portainer and expose port 9000:
docker run -d \
--name portainer \
--restart=always \
-p 9000:9000 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
portainer/portainer-ce:latest
Warning: if you expose Portainer on
9000, it is recommended to add protection so that port9000is not reachable directly from outside. A good real-world setup is to bind it only locally or block it with your firewall or security group, then let Caddy be the public entry point.
Now extend the Caddyfile so one hostname serves files and the other proxies requests:
portainer-example.domain.tld {
reverse_proxy localhost:9000
}
Where it gets less easy#
This is the honest part: Caddy is super easy on the happy path, but more advanced setups are where the story changes.
DNS challenge#
If you need wildcard certificates or you cannot use the normal HTTP challenge on port 80, the DNS challenge is the usual next step.
Useful docs:
- Caddy
tlsdirective docs - How to use DNS provider modules in Caddy 2
- Find DNS provider modules
- Cloudflare DNS provider module
One practical Ubuntu example is to install Go with apt, install xcaddy from the official package repo, and then build a custom Caddy binary with the Cloudflare DNS provider plugin.
For other systems, see the official xcaddy install instructions.
sudo apt update
sudo apt install -y golang-go
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-xcaddy-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-xcaddy.list
sudo apt update
sudo apt install -y xcaddy
xcaddy build --with github.com/caddy-dns/cloudflare
Replace github.com/caddy-dns/cloudflare with your DNS provider module if you are not using Cloudflare.
The result is a custom caddy binary in the current folder. How you roll that into your actual service depends on whether you run Caddy from a package, a container, or a manual binary install.
For Cloudflare specifically, the recommended auth method is a scoped API token with at least:
Zone.Zone:ReadZone.DNS:Edit
Then configure your Caddyfile to use the DNS challenge:
example.com, *.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1
}
reverse_proxy 127.0.0.1:9000
}
If you want to apply the same DNS challenge provider globally for all sites, you can also do it at the top of the Caddyfile:
{
acme_dns cloudflare {env.CF_API_TOKEN}
}
Important: if Caddy runs as a systemd service, make sure CF_API_TOKEN is available to that service, not just your current shell.
Docker labels#
If you want Docker metadata and labels to generate proxy configuration automatically instead of maintaining a Caddyfile by hand, the most common community project is:
That project watches Docker labels, generates an in-memory Caddyfile, and reloads Caddy automatically when containers change. This can be really convenient, but it is also one of the places where the setup becomes less “super easy” and more opinionated.

