How to set up Caddy as a reverse proxy with automatic HTTPS
Install Caddy from its official apt repository on Ubuntu or Debian and put several apps behind automatic HTTPS, with security headers, basic auth and logs.
- Beginner
- 30 min read
- Updated
Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13
This guide is not available in your language yet, so it is shown in English.
On this page
- Prerequisites
- Step 1 — Point DNS at the server and open the firewall
- Step 2 — Install Caddy from the official repository
- Step 3 — Proxy your first app
- Step 4 — Validate, reload and verify
- Step 5 — Add more sites and common security headers
- Step 6 — Protect an admin interface with a password
- Step 7 — Write access logs
- Back up and restore
- Update Caddy
- Troubleshooting
- Certificate is not issued: Timeout during connect (likely firewall problem)
- DNS problem: NXDOMAIN looking up A for app.example.com
- urn:ietf:params:acme:error:rateLimited
- listen tcp :443: bind: address already in use
- 502 Bad Gateway
- open /var/log/caddy/app.example.com.log: permission denied
- Next steps
Caddy is a web server and reverse proxy that obtains and renews HTTPS certificates on its own. You name a domain, point it at an app, and Caddy handles Let's Encrypt, the HTTP to HTTPS redirect and renewals. That makes it the default HTTPS front end for the self-hosted apps in this library: each app listens on a local port such as 127.0.0.1:8080, and Caddy publishes it on ports 80 and 443. This guide installs Caddy from its official apt repository on Ubuntu or Debian, opens the firewall, proxies several apps, adds security headers, password protection and access logs, and shows how to test certificates safely, back up, update and troubleshoot.
Prerequisites
- A server running Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12 or Debian 13. Caddy's apt repository is not tied to one release.
- A non-root user with
sudorights and SSH key login: Secure a new Linux server and Set up SSH keys. - A domain name with an A record (and an AAAA record if the server has working IPv6) pointing at the server's public IP for every hostname you want to serve, for example
app.example.com. - Ports 80 and 443 free on the server: no Apache, Nginx or containerised proxy already listening there.
- At least one app listening on a local port. For Docker apps, publish them on
127.0.0.1only, as explained in Docker Compose basics.
The Caddy project does not publish minimum hardware requirements; Caddy itself is a single binary with a small footprint. The figures below are a conservative starting point for Caddy alone; size the server for the apps behind it.
| Resource | Minimum (official) | Suggested starting point |
|---|---|---|
| CPU | Not published | 1 vCPU, shared with your apps |
| RAM | Not published | 256 MB free for Caddy |
| Disk | Not published | 1 GB free for certificates and logs |
Step 1 — Point DNS at the server and open the firewall
Caddy can only get a certificate when Let's Encrypt can reach your server under the domain name. Before you start Caddy, create the DNS records and check that they resolve to the server:
dig +short A app.example.com
dig +short AAAA app.example.comThe first command should print your server's IPv4 address, for example 203.0.113.10. If the second prints an address, IPv6 must work on the server too, because Let's Encrypt prefers IPv6 when an AAAA record exists. Remove the AAAA record if the server has no working IPv6.
Allow SSH, HTTP and HTTPS in ufw. Caddy serves HTTP/3 by default, which runs over UDP, so open 443/udp as well. On Debian, install ufw first with sudo apt install ufw.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbosePort 80 must stay open even though all traffic ends up on HTTPS: Caddy uses it for the HTTP challenge and for redirecting visitors to HTTPS.
Step 2 — Install Caddy from the official repository
Caddy publishes Debian and Ubuntu packages through its Cloudsmith repository. Install the prerequisites, add the signing key and the repository, then install the package:
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 caddyThe same commands work on Ubuntu and Debian. The package does several things for you:
- creates a
caddysystem user whose home directory/var/lib/caddyholds certificates and other state, - creates
/var/log/caddyowned by that user, - installs the configuration file
/etc/caddy/Caddyfile, - installs and starts the
caddysystemd service, which runscaddy run --config /etc/caddy/Caddyfileas thecaddyuser.
Check that the service is running:
caddy version
systemctl status caddy --no-pager
curl -I http://localhostThe default Caddyfile serves a welcome page on port 80, so curl prints HTTP/1.1 200 OK.
Step 3 — Proxy your first app
Open the Caddyfile:
sudo nano /etc/caddy/CaddyfileReplace its contents with a global options block and one site block. The email address is used for your ACME account with the certificate authority:
{
email [email protected]
}
app.example.com {
reverse_proxy 127.0.0.1:8080
}That is the entire configuration for one HTTPS site. Because app.example.com is a public domain name, Caddy automatically:
- obtains a certificate from Let's Encrypt, and tries ZeroSSL if Let's Encrypt fails,
- redirects
http://app.example.comto HTTPS, - renews the certificate in the background long before it expires,
- passes the original
Hostheader to the app and setsX-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Host, - proxies WebSocket connections and streams responses such as server-sent events, with no extra directives.
Step 4 — Validate, reload and verify
Check the configuration as the caddy user, so that any file the check creates, such as a new log file, gets the right owner:
sudo -H -u caddy caddy validate --config /etc/caddy/CaddyfileYou should see Valid configuration. A warning that the input is not formatted is harmless; fix it with sudo caddy fmt --overwrite /etc/caddy/Caddyfile. Then apply the configuration without downtime:
sudo systemctl reload caddyIf the new configuration fails to load, Caddy keeps running the old one, so a typo does not take your sites offline. Watch the certificate being issued:
journalctl -u caddy --no-pager -n 50Look for a line saying the certificate was obtained successfully for app.example.com. Then test from the server or your own computer:
curl -I http://app.example.com
curl -I https://app.example.comThe first request returns 308 Permanent Redirect with a Location: https://app.example.com/ header; the second returns your app's response, usually HTTP/2 200. Certificates and ACME account keys are stored under /var/lib/caddy/.local/share/caddy.
Step 5 — Add more sites and common security headers
Each additional app gets its own site block pointing at its own local port. Repeated settings go into a snippet, a named block in parentheses that you include with import. The following Caddyfile serves two apps, compresses responses and adds conservative security headers:
{
email [email protected]
}
(common) {
encode zstd gzip
header {
?Strict-Transport-Security "max-age=31536000;"
?X-Content-Type-Options "nosniff"
?X-Frame-Options "SAMEORIGIN"
?Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
app.example.com {
import common
reverse_proxy 127.0.0.1:8080
}
status.example.com {
import common
reverse_proxy 127.0.0.1:3001
}The ? prefix sets a header only if the app did not already send it, so an app with its own, stricter headers keeps them. -Server removes the Server: Caddy header. Validate and reload as in Step 4 after every change; Caddy requests a certificate for each new hostname on its own.
If your apps sit behind a CDN or another proxy, add its address ranges to the trusted_proxies server option so that Caddy accepts the visitor address the CDN forwards. By default, Caddy trusts no proxies and ignores incoming X-Forwarded-* headers.
Step 6 — Protect an admin interface with a password
Some tools have no login of their own, or should not be reachable without an extra layer. Caddy's basic_auth directive asks for a username and password before any request reaches the app. Passwords are stored as hashes; generate one with:
caddy hash-passwordType the password twice when asked (it is not shown) and copy the printed hash, which starts with $2a$. The default algorithm is bcrypt; caddy hash-password --algorithm argon2id produces an argon2id hash, which Caddy's documentation recommends. Add the user to the site block:
tools.example.com {
import common
basic_auth {
# paste the hash printed by caddy hash-password
alice HASH_FROM_CADDY_HASH_PASSWORD
}
reverse_proxy 127.0.0.1:9000
}If you used argon2id, write basic_auth argon2id { instead of basic_auth {. Validate and reload, then check that curl -I https://tools.example.com returns 401 Unauthorized, while a browser asks for the credentials. Basic auth sends the password with every request, which is acceptable only over HTTPS, as here. If the app behind it uses HTTP basic auth itself, the two logins clash; protect such apps with their own login instead.
Step 7 — Write access logs
Caddy does not write access logs unless you ask for it. Add a log block to a site to record every request in JSON format:
app.example.com {
import common
log {
output file /var/log/caddy/app.example.com.log
}
reverse_proxy 127.0.0.1:8080
}By default, the file rolls over at 100 MiB, Caddy keeps 10 old files and deletes files older than 90 days, so logs cannot fill the disk. The Authorization and Cookie headers are logged as REDACTED. Validate with the command from Step 4, reload, and follow the log:
sudo tail -f /var/log/caddy/app.example.com.logCaddy's own messages, including certificate events and errors, go to the systemd journal: journalctl -u caddy -f.
Back up and restore
Caddy's state is small:
/etc/caddy/— your Caddyfile and anything you import from it,/var/lib/caddy/.local/share/caddy/— certificates, private keys and the ACME account.
Certificates can always be requested again, but restoring them avoids hitting rate limits when you rebuild a server with many hostnames. Create the backup:
sudo mkdir -p /opt/backups
sudo tar czf /opt/backups/caddy-$(date +%F).tar.gz /etc/caddy /var/lib/caddy/.local/share/caddyTo restore on a new server, install Caddy as in Step 2, then unpack the archive, fix ownership and reload:
sudo tar xzf /opt/backups/caddy-2026-10-09.tar.gz -C /
sudo chown -R caddy:caddy /var/lib/caddy/.local
sudo systemctl reload caddyThe archive contains private keys, so store it with restricted permissions and copy it off the server to a safe place.
Update Caddy
Caddy updates arrive through the apt repository you added:
sudo apt update
sudo apt install --only-upgrade caddy
caddy version
systemctl status caddy --no-pagerRead the release notes on Caddy's GitHub releases page before a minor version upgrade, and keep a backup of /etc/caddy. Do not use caddy upgrade on a package install, because it replaces the binary behind apt's back.
Troubleshooting
Certificate is not issued: Timeout during connect (likely firewall problem)
Let's Encrypt could not reach the server on port 80 or 443. Check sudo ufw status, any firewall in front of the server, and that nothing else listens on those ports. If the domain has an AAAA record, test IPv6 from another machine with curl -6 -I http://app.example.com; a stale AAAA record is a common cause. Caddy retries on its own with increasing delays, so after fixing the cause, reload and watch journalctl -u caddy -f.
DNS problem: NXDOMAIN looking up A for app.example.com
The DNS record does not exist yet, has a typo, or points elsewhere. Check with dig +short A app.example.com from your own computer as well; new records can take a few minutes to propagate.
urn:ietf:params:acme:error:rateLimited
You hit a Let's Encrypt rate limit. Let's Encrypt allows, among other limits, 5 certificates for the exact same set of hostnames every 7 days and 5 failed validations per hostname per hour. Fix the underlying problem, test with the staging CA (Step 3 tip), and wait for the limit to refill. Restoring /var/lib/caddy/.local/share/caddy from a backup avoids new issuances after a rebuild.
listen tcp :443: bind: address already in use
Another program uses the port. Find it with sudo ss -tlpn 'sport = :443'. Typical culprits are Apache, Nginx or a container publishing port 80 or 443. Stop and disable it, for example sudo systemctl disable --now apache2, then sudo systemctl restart caddy.
502 Bad Gateway
Caddy works, but the app behind it does not answer. Test the upstream from the server with curl -I http://127.0.0.1:8080. If that fails, the app is down or listens on another port; for Docker apps check docker compose ps and the port mapping.
open /var/log/caddy/app.example.com.log: permission denied
The log file was created by root, for example by running caddy validate with plain sudo. Give it back to the service user with sudo chown caddy:caddy /var/log/caddy/*.log and reload. Validate as shown in Step 4 to avoid it.
Next steps
- Learn how app guides structure their containers in Docker Compose basics.
- Compare with Nginx and Certbot or the container-based Traefik.
- Harden the rest of the server with Secure a new Linux server.
- Find a server for your containers on the Docker hosting page.
- Read the official Caddyfile documentation for matchers, snippets and more directives.
Frequently asked questions
Do I have to configure Let’s Encrypt for Caddy myself?
No. As soon as a site block names a public domain, Caddy gets a certificate from Let’s Encrypt (or ZeroSSL as a fallback), redirects HTTP to HTTPS and renews the certificate in the background. You only need DNS records pointing at the server and ports 80 and 443 reachable.
Does Caddy support WebSockets and server-sent events?
Yes, without extra configuration. reverse_proxy passes WebSocket upgrades through and flushes streaming responses such as event streams immediately. Long-lived WebSocket connections are closed when you reload the configuration, and clients reconnect.
Can I run Caddy next to Nginx or Apache on the same server?
Not on the same ports. Only one program can listen on ports 80 and 443. Stop and disable the other web server, or let Caddy proxy to it on a local port such as 127.0.0.1:8081.
Why do my apps see Caddy’s IP instead of the visitor’s?
Caddy sends the visitor’s address in the X-Forwarded-For header. Configure the app to trust the proxy at 127.0.0.1 and read that header; most apps have a trusted proxies setting for this.
Should I use caddy upgrade to update Caddy?
Not when Caddy came from the apt repository. Update it with apt like any other package, so the package manager stays in charge of the binary and the systemd service.
Sources
- caddyserver.com/docs/install
- caddyserver.com/docs/running
- caddyserver.com/docs/automatic-https
- caddyserver.com/docs/caddyfile/concepts
- caddyserver.com/docs/caddyfile/options
- caddyserver.com/docs/caddyfile/directives/reverse_proxy
- caddyserver.com/docs/caddyfile/directives/header
- caddyserver.com/docs/caddyfile/directives/basic_auth
- caddyserver.com/docs/caddyfile/directives/log
- caddyserver.com/docs/command-line
- caddyserver.com/docs/api
- raw.githubusercontent.com/caddyserver/dist/master/init/caddy.service