Beginner 20 min — Serve and proxy your web services with automatic TLS certificates and zero hassle.
Caddy is a modern, open-source web server written in Go. It stands out from traditional servers like Nginx and Apache for three key reasons:
Caddy v2 is production-ready and widely used for self-hosted dashboards, game panels, API gateways, and static sites. If you are tired of fighting with Nginx config syntax or renewing certificates by hand, Caddy is worth a serious look.
On Debian or Ubuntu, the recommended installation method is via the official apt repository. This ensures you get automatic updates through your package manager.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
Add the Caddy GPG key and repository:
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
Install Caddy:
sudo apt update
sudo apt install caddy
Verify the installation:
caddy version
The package automatically creates a caddy systemd service, a caddy user, and the default config directory at /etc/caddy/. Caddy is now running and serving a placeholder page on port 80.
The Caddyfile lives at /etc/caddy/Caddyfile. Open it in your editor:
sudo nano /etc/caddy/Caddyfile
Replace the contents with a simple static file server:
example.com {
root * /var/www/mysite
file_server
}
Here is how Caddyfile syntax works:
example.com) is the site address. When it is a domain name, Caddy automatically enables HTTPS.{ } wrap the site block, which contains directives for that site.root * /var/www/mysite sets the document root. The * matches all request paths.file_server enables the static file server, which serves files from the root directory.Apply the new config by reloading Caddy:
sudo systemctl reload caddy
You can also validate your Caddyfile before reloading:
caddy validate --config /etc/caddy/Caddyfile
The most common use case for Caddy is proxying traffic to an application running on localhost. For example, if you have a Node.js app, a game panel, or Grafana listening on port 3000:
example.com {
reverse_proxy localhost:3000
}
That is the entire configuration. Caddy will:
example.com automaticallylocalhost:3000X-Forwarded-For, X-Forwarded-Proto, etc.)If your backend uses WebSockets (common for game panels and real-time apps), Caddy handles the upgrade automatically — no extra config needed.
Reload to apply:
sudo systemctl reload caddy
To proxy multiple subdomains to different backend services, add one site block per subdomain:
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
grafana.example.com {
reverse_proxy localhost:3100
}
Each subdomain gets its own TLS certificate automatically. Make sure each subdomain has a DNS A record pointing to your server's public IP.
You can also route by path instead of subdomain if you prefer a single domain:
example.com {
handle /api/* {
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:3000
}
}
The handle directive creates mutually exclusive route groups. The first matching block handles the request. The final handle without a matcher acts as the fallback.
Caddy uses the ACME protocol to obtain free TLS certificates from Let's Encrypt. Here is what happens behind the scenes:
/var/lib/caddy/.local/share/caddy/, and renewed automatically before expiry (certificates last 90 days, Caddy renews at roughly 30 days remaining).For this to work, you need:
If you are behind a firewall or NAT, forward both ports 80 and 443 TCP to your server.
For local development or internal services where you do not need a public certificate, use localhost or an IP address as the site address. Caddy will generate a self-signed certificate instead:
localhost {
reverse_proxy localhost:3000
}
To use the DNS-01 challenge instead (useful when port 80 is blocked), you need a Caddy build with a DNS provider plugin. For example, with the Cloudflare module:
example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy localhost:3000
}
Use the header directive to add security-related HTTP headers. These protect your site from common attacks:
example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Content-Security-Policy "default-src 'self'"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
reverse_proxy localhost:3000
}
What each header does:
max-age is in seconds (31536000 = 1 year).SAMEORIGIN instead of DENY if your app uses iframes internally.default-src 'self' restricts everything to same-origin; adjust as needed for your app.Server header from responses, hiding the server software identity.Enable response compression with the encode directive. Caddy supports gzip and Zstandard (zstd):
example.com {
encode zstd gzip
reverse_proxy localhost:3000
}
The order matters: list zstd first because it is a more efficient algorithm. Caddy will negotiate with the client's Accept-Encoding header and use the best available option. Modern browsers support gzip universally; zstd support is growing.
Compression is applied to text-based content types (HTML, CSS, JavaScript, JSON, etc.) automatically. Binary files like images and videos are skipped since they are already compressed.
When multiple site blocks share the same configuration (like security headers and compression), use snippets to avoid repeating yourself. Define a snippet with parentheses at the top of your Caddyfile, then import it:
(common) {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
-Server
}
}
app.example.com {
import common
reverse_proxy localhost:3000
}
api.example.com {
import common
reverse_proxy localhost:8080
}
grafana.example.com {
import common
reverse_proxy localhost:3100
}
Snippets keep your Caddyfile DRY and maintainable. You can define as many snippets as you need, and they can contain any valid Caddyfile directives.
The import directive can also load external files, which is useful for splitting large configurations:
import /etc/caddy/sites/*.caddy
This imports every file matching the glob pattern, letting you keep one file per site.
dig +short example.com. The returned IP must match your server. Caddy retries certificate issuance automatically, so once DNS resolves correctly, the certificate will be obtained.sudo ss -tlnp | grep :80. Stop any conflicting service (Apache, Nginx, etc.) or switch to DNS-01 challenge.sudo ss -tlnp | grep :3000. Also check that the backend is bound to 127.0.0.1 or 0.0.0.0, not a different interface.caddy validate --config /etc/caddy/Caddyfile. Common mistakes include missing closing braces, tabs instead of spaces in certain contexts, and using Caddy v1 syntax (like proxy instead of reverse_proxy).cap_net_bind_service. If you installed Caddy manually, run: sudo setcap cap_net_bind_service=+ep $(which caddy).journalctl -u caddy -f. For more verbose output, add a global debug option at the top of your Caddyfile:{
debug
}
example.com {
reverse_proxy localhost:3000
}