Resources·Tutorials

Set up automatic HTTPS with Caddy on a VPS

Caddy gives you free, auto-renewing TLS certificates with zero config. This walkthrough installs Caddy on a fresh VPS, reverse-proxies a Node app on port 3000, and gets a green padlock in under five minutes.

By RareCloud Team · 6 min read · 20 May 2026 · Updated 8 Oct 2026

You shouldn't be writing nginx configs in 2026. Caddy gets you a green padlock in three lines of config, renews the cert before it expires, and has reasonable defaults. Here's the full setup.

Prerequisites

  • A VPS running Ubuntu / Debian (this guide uses Ubuntu 24.04)
  • A domain pointing at the VPS's IPv4. A record propagated (check with dig +short example.com)
  • Ports 80 and 443 open in the firewall (the default firewall on a RareCloud Cloud VM already allows SSH, HTTP, HTTPS and ping)

Install Caddy

The official Caddy repository provides apt packages:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
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 apt update
sudo apt install -y caddy

Caddy is now running as a systemd service. Confirm:

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

Write the config

Caddy's config lives in /etc/caddy/Caddyfile. The simplest possible "give me HTTPS for example.com and reverse-proxy to my app on :3000" looks like this:

example.com {
    reverse_proxy localhost:3000
}

That's it. Three lines. No certificate paths, no listen directives, no challenge config. Caddy will:

  1. Listen on 443 (TLS) and 80 (redirect to HTTPS).
  2. Solicit a Let's Encrypt certificate via HTTP-01 challenge.
  3. Renew the cert 30 days before expiry.
  4. Reverse-proxy every request to your Node / Python / Go app on port 3000.

Reload Caddy:

$ sudo systemctl reload caddy

First request to https://example.com will take a few seconds (cert provisioning); subsequent requests are instant.

Multiple sites

Add another site block:

example.com {
    reverse_proxy localhost:3000
}

api.example.com {
    reverse_proxy localhost:4000
    encode gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
    }
}

static.example.com {
    root * /var/www/static
    file_server
}

Each block is independent. Reload Caddy and all three pick up certificates on their first request.

Real client IPs through the proxy

Your app's socket peer is Caddy (127.0.0.1), but reverse_proxy already sends X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host with the real values. If your app prefers a single X-Real-IP header, add it:

example.com {
    reverse_proxy localhost:3000 {
        header_up X-Real-IP {remote_host}
    }
}

Then read the header in your app. Express, FastAPI, Fiber, etc. all have middleware that respects these headers (often via trust proxy setting).

Behind Cloudflare

If you want Cloudflare's CDN + DDoS in front:

  1. In Cloudflare DNS, set the A record to proxy mode (orange cloud).
  2. In Cloudflare SSL/TLS, set mode to "Full (strict)", this means Cloudflare validates Caddy's cert.
  3. In your Caddyfile, trust Cloudflare's edge and read the visitor IP from its header (Caddy 2.7 or newer):
{
    servers {
        # Cloudflare's edge ranges; paste the full list
        trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22
        client_ip_headers CF-Connecting-IP
    }
}

example.com {
    reverse_proxy localhost:3000 {
        header_up X-Real-IP {client_ip}
    }
}

Cloudflare's edge IP list is at cloudflare.com/ips. Keep it updated.

Troubleshooting

  • Certificate challenge fails → port 80 isn't open or DNS isn't pointing at this VPS. Verify with curl -v http://example.com (should hit your server) and dig +short example.com (should return the VPS IP).
  • Cert renewal failing → check journalctl -u caddy -n 100. Common causes: DNS or port 80 changed since the first issue, or a Let's Encrypt rate limit (50 certificates per registered domain per week, easy to hit if you wipe Caddy's data directory between restarts).
  • 502 Bad Gateway → your app isn't listening on the port Caddy is reverse-proxying to. Test with curl localhost:3000 on the VPS.

Where to go next

Frequently Asked Questions

Why Caddy and not nginx?
Caddy ships with automatic Let's Encrypt provisioning and renewal out of the box. nginx can do TLS, but you'll end up configuring certbot, writing a renewal cron, and dealing with stapling. Caddy's config file is also dramatically shorter. nginx is more flexible at scale; Caddy wins for everything else.
Does Caddy work behind Cloudflare?
Yes. With Cloudflare proxying on (orange cloud), set encryption mode to Full or Full (strict). Caddy still gets a real cert from Let's Encrypt, and Cloudflare presents its own to your visitors. Add Cloudflare's ranges to Caddy's trusted_proxies so logs and your app see the visitor IP, not Cloudflare's edge.
What if port 80 or 443 is firewalled?
Caddy needs port 80 for HTTP-01 challenge. If you can't open it, switch to DNS-01 challenge. Caddy supports it via plugins for major DNS providers (Cloudflare, Route53, etc). Then you only need port 443 open.

Related