# 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.

Difficulty: Beginner\
Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13

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.

> **Note**
>
> Prefer a different proxy? [Nginx with Certbot](/guides/nginx-reverse-proxy-certbot) suits teams that already know Nginx, and [Traefik](/guides/traefik-reverse-proxy) or [Nginx Proxy Manager](/guides/nginx-proxy-manager) run as containers. Use only one of them on a server, because they all need ports 80 and 443.

## 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 `sudo` rights and SSH key login: [Secure a new Linux server](/guides/secure-a-new-linux-server) and [Set up SSH keys](/guides/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.1` only, as explained in [Docker Compose basics](/guides/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:

```bash
dig +short A app.example.com
dig +short AAAA app.example.com
```

The 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`.

```bash
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 verbose
```

Port 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:

```bash
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
```

The same commands work on Ubuntu and Debian. The package does several things for you:

- creates a `caddy` system user whose home directory `/var/lib/caddy` holds certificates and other state,
- creates `/var/log/caddy` owned by that user,
- installs the configuration file `/etc/caddy/Caddyfile`,
- installs and starts the `caddy` systemd service, which runs `caddy run --config /etc/caddy/Caddyfile` as the `caddy` user.

Check that the service is running:

```bash
caddy version
systemctl status caddy --no-pager
curl -I http://localhost
```

The 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:

```bash
sudo nano /etc/caddy/Caddyfile
```

Replace its contents with a global options block and one site block. The email address is used for your ACME account with the certificate authority:

```caddyfile
{
    email admin@example.com
}

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.com` to HTTPS,
- renews the certificate in the background long before it expires,
- passes the original `Host` header to the app and sets `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`,
- proxies WebSocket connections and streams responses such as server-sent events, with no extra directives.

> **Tip**
>
> While you experiment, add `acme_ca https://acme-staging-v02.api.letsencrypt.org/directory` to the global options block. Let's Encrypt's staging CA has much higher rate limits, but its certificates are not trusted by browsers. Remove the line and reload once everything works, and Caddy fetches trusted certificates.

## 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:

```bash
sudo -H -u caddy caddy validate --config /etc/caddy/Caddyfile
```

You 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:

```bash
sudo systemctl reload caddy
```

If 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:

```bash
journalctl -u caddy --no-pager -n 50
```

Look for a line saying the certificate was obtained successfully for `app.example.com`. Then test from the server or your own computer:

```bash
curl -I http://app.example.com
curl -I https://app.example.com
```

The 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:

```caddyfile
{
    email admin@example.com
}

(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.

> **Warning**
>
> `Strict-Transport-Security` (HSTS) tells browsers to refuse plain HTTP for this hostname for the given time, here one year. Caddy always serves HTTPS, so this is normally safe, but do not add `includeSubDomains` or `preload` unless every subdomain of the domain serves valid HTTPS.

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:

```bash
caddy hash-password
```

Type 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:

```caddyfile
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:

```caddyfile
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:

```bash
sudo tail -f /var/log/caddy/app.example.com.log
```

Caddy'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:

```bash
sudo mkdir -p /opt/backups
sudo tar czf /opt/backups/caddy-$(date +%F).tar.gz /etc/caddy /var/lib/caddy/.local/share/caddy
```

To restore on a new server, install Caddy as in Step 2, then unpack the archive, fix ownership and reload:

```bash
sudo tar xzf /opt/backups/caddy-2026-10-09.tar.gz -C /
sudo chown -R caddy:caddy /var/lib/caddy/.local
sudo systemctl reload caddy
```

The 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:

```bash
sudo apt update
sudo apt install --only-upgrade caddy
caddy version
systemctl status caddy --no-pager
```

Read 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](/guides/docker-compose-basics).
- Compare with [Nginx and Certbot](/guides/nginx-reverse-proxy-certbot) or the container-based [Traefik](/guides/traefik-reverse-proxy).
- Harden the rest of the server with [Secure a new Linux server](/guides/secure-a-new-linux-server).
- Find a server for your containers on the [Docker hosting](/docker-hosting) page.
- Read the official [Caddyfile documentation](https://caddyserver.com/docs/caddyfile/concepts) 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.

---

Source: <https://hyperdc.com/guides/tutorials/caddy-reverse-proxy>\
Updated: 2026-10-09
