How to Set Up a Reverse Proxy with Caddy

by David Park
How to Set Up a Reverse Proxy with Caddy

What You'll End Up With

By the end of this guide you'll have Caddy running as a reverse proxy on Ubuntu 24.04, forwarding HTTPS traffic from a public domain to a backend service running on localhost. Caddy handles TLS certificate provisioning and renewal automatically via Let's Encrypt — no Certbot, no cron jobs, no manual cert management. This setup is what I run in production for several services on a single $6 Hetzner VPS.

Prerequisites:

  • Ubuntu 24.04 server with a public IP address
  • A domain name (e.g. app.example.com) with an A record pointing to that IP
  • A backend service already listening on a local port (e.g. 127.0.0.1:3000)
  • Root or sudo access
  • Ports 80 and 443 open in your firewall
  • curl installed

Step 1: Install Caddy from the Official Repository

Caddy is not in Ubuntu's default apt sources. Add the official Caddy repository so you get signed, up-to-date packages.

1.1 Install required dependencies.

sudo apt update && sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl

1.2 Add the Caddy GPG key.

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg

1.3 Add the Caddy apt repository.

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list

1.4 Install Caddy.

sudo apt update && sudo apt install -y caddy

Expected output (last few lines):

Setting up caddy (2.x.x) ...
Created symlink /etc/systemd/system/multi-user.target.wants/caddy.service ...

The package installs the caddy binary, creates a caddy system user, and registers a systemd unit at caddy.service.


Step 2: Understand the Default File Layout

Before editing anything, know where Caddy's files live on a package-installed system.

Path Purpose
/etc/caddy/Caddyfile Main configuration file
/var/lib/caddy/.local/share/caddy TLS certificates and ACME state
/var/log/caddy/ Log output (if configured)
/usr/bin/caddy The binary
caddy.service systemd unit name

Caddy runs as the caddy user. That user owns the certificate storage directory. Do not change those permissions.


Step 3: Write the Caddyfile

The Caddyfile is Caddy's configuration format. It is concise — a basic reverse proxy block is four lines.

3.1 Open the default Caddyfile.

sudo nano /etc/caddy/Caddyfile

3.2 Replace the entire contents with the following. Substitute app.example.com with your actual domain and 3000 with your backend port.

app.example.com {
    reverse_proxy 127.0.0.1:3000
}

What each line does:

  • app.example.com { } — declares a virtual host; Caddy will automatically obtain and renew a TLS certificate for this hostname.
  • reverse_proxy 127.0.0.1:3000 — forwards all incoming requests to the backend and streams the response back to the client.

Save and close the file (Ctrl+O, Enter, Ctrl+X in nano).

3.3 Validate the configuration before reloading.

caddy validate --config /etc/caddy/Caddyfile

Expected output:

Valid configuration

If you see an error here, fix the Caddyfile before proceeding. A typo in the domain or a missing brace will be reported with a line number.


Step 4: Reload Caddy

Use reload rather than restart. Reload performs a zero-downtime configuration swap — Caddy parses the new config, starts new listeners, and only then tears down the old ones.

4.1 Reload the running Caddy process.

sudo systemctl reload caddy

4.2 Confirm the service is still active.

sudo systemctl status caddy

Expected output (abbreviated):

● caddy.service - Caddy
     Loaded: loaded (/usr/lib/systemd/system/caddy.service; enabled)
     Active: active (running) since ...

If the status shows failed, check logs immediately with sudo journalctl -u caddy -n 50 --no-pager.


Step 5: Configure Caddy to Start on Boot

The apt package enables the unit automatically, but verify it.

5.1 Check that the unit is enabled.

sudo systemctl is-enabled caddy

Expected output:

enabled

5.2 If the output is disabled, enable it now.

sudo systemctl enable caddy

Step 6: Add Request Headers (Optional but Recommended)

By default, Caddy's reverse_proxy directive passes the client IP in the X-Forwarded-For header. Many applications also expect X-Real-IP and X-Forwarded-Proto. Add them explicitly to avoid misconfigured application logs or broken redirect logic.

6.1 Edit the Caddyfile again.

sudo nano /etc/caddy/Caddyfile

6.2 Expand the block.

app.example.com {
    reverse_proxy 127.0.0.1:3000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }

    log {
        output file /var/log/caddy/app.example.com.log
        format json
    }
}

What the new directives do:

  • header_up X-Real-IP {remote_host} — sets the header to the client's IP address using Caddy's built-in placeholder.
  • header_up X-Forwarded-Proto {scheme} — tells the backend whether the original request was HTTP or HTTPS.
  • log { output file ... format json } — writes structured JSON access logs to /var/log/caddy/. The caddy user must be able to write there; the package creates that directory with correct ownership.

6.3 Validate and reload.

caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

Step 7: Proxy Multiple Domains (Optional)

If you run more than one service on the same server, add additional blocks to the same Caddyfile. Each block gets its own certificate.

app.example.com {
    reverse_proxy 127.0.0.1:3000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }

    log {
        output file /var/log/caddy/app.example.com.log
        format json
    }
}

api.example.com {
    reverse_proxy 127.0.0.1:4000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }

    log {
        output file /var/log/caddy/api.example.com.log
        format json
    }
}

Validate and reload after every edit.

caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

Verify It Works

Run these checks in order. Each one confirms a different layer of the stack.

Check 1 — Backend is reachable locally.

curl -s http://127.0.0.1:3000/

You should get an HTTP response from your app. If this fails, the problem is with your backend, not Caddy.

Check 2 — HTTPS is serving the correct certificate.

curl -sv https://app.example.com/ 2>&1 | grep -E 'SSL connection|subject|issuer|HTTP/'

Expected output (key lines):

* SSL connection using TLSv1.3 / ...
* subject: CN=app.example.com
* issuer: C=US; O=Let's Encrypt; CN=...
< HTTP/2 200

Check 3 — Certificate auto-renewal is configured.

sudo caddy environ | grep CADDY

This prints Caddy's runtime environment. As long as Caddy is running, it manages renewals internally — no external cron job needed.

Check 4 — Logs are being written.

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

Make a request to your domain in a browser and watch the JSON log line appear.


Troubleshooting

Caddy fails to obtain a TLS certificate. The most common cause is a DNS propagation delay or a firewall blocking port 80. Caddy uses HTTP-01 ACME challenge, which requires port 80 to be reachable from the internet. Run sudo ufw allow 80/tcp and sudo ufw allow 443/tcp, then reload Caddy. Check ACME errors with sudo journalctl -u caddy -n 100 --no-pager | grep -i acme.

caddy validate reports "permission denied". The caddy binary needs to be run as root or with sudo when validating a file in /etc/. Use sudo caddy validate --config /etc/caddy/Caddyfile.

502 Bad Gateway from Caddy. Caddy is running but cannot reach the backend. Confirm the backend is listening: ss -tlnp | grep 3000. If the backend crashed, restart it. Caddy will automatically retry once the backend is back up.

Caddy starts but immediately shows active (running) then failed. Another process is occupying port 80 or 443. Find it: sudo ss -tlnp | grep -E ':80|:443'. Stop or reconfigure the conflicting service (often Apache or Nginx left over from a previous setup).

Logs not appearing at /var/log/caddy/. The directory exists but Caddy may lack write permission if you created it manually. Fix ownership: sudo chown caddy:caddy /var/log/caddy.

Changes to Caddyfile have no effect. You edited the file but forgot to reload. Run sudo systemctl reload caddy. A restart works too but drops connections briefly.


Next Steps

Now that you have a reverse proxy with Caddy running, consider these additions:

  • Rate limiting — Caddy's rate_limit directive (available via the caddy-ratelimit plugin) protects backend services from traffic spikes.
  • Basic authentication — Add basicauth blocks to password-protect internal tools without touching application code.
  • Wildcard certificates — If you manage many subdomains, switch to DNS-01 ACME challenge with a supported DNS provider plugin to issue a single *.example.com certificate.
  • Metrics — Enable Caddy's built-in Prometheus metrics endpoint with metrics in the global options block and scrape it with a local Prometheus instance.

The Caddyfile you built here is production-ready for most indie hacker workloads. One file, automatic HTTPS, structured logs — that's the whole setup.