Caddy Reverse Proxy: HTTPS for Your Site in 5 Minutes

2026-09-28 · 8 min read

Your site runs on the server listening on localhost:3000. For users to reach it via https://your-domain.com, you need a reverse proxy in between. What's a reverse proxy? An analogy: your site is the kitchen, Caddy is the host at the door — guests (browsers) only talk to the host, who passes the order to the kitchen and brings the food out; guests never need to know where the kitchen is or what it looks like. The wins: only two standard ports (80/443) face the public; how many services run behind and what ports they listen on stay hidden; and the host manages HTTPS certificates uniformly. Why Caddy in one sentence: it requests and renews HTTPS certificates automatically — you never think about certificates. Nginx works too, but certificates mean fiddling with certbot yourself; beginners should start with Caddy.

Caddy reverse-proxy flow: browser → Caddy on 443 with automatic HTTPS → local service on 3000BrowserYour visithttps://your-domain.com443CaddyListens on 80 / 443Auto HTTPS certificatesProxyYour websitelocalhost:3000No cert hassleFree Let's Encrypt certs, auto-renewed — no HTTPS config neededKeep ports 80/443 free, and point your domain's DNS at this server first
Caddy reverse-proxy flow: browsers enter Caddy on 443, Caddy forwards to the site on local port 3000

Minimal config: two lines is enough

Caddy's config file is called the Caddyfile, and its syntax is human language. Proxying one domain to local port 3000 is these two lines in full:

example.com {
	reverse_proxy localhost:3000
}

Replace example.com with your domain. reverse_proxy means: forward received requests as-is to localhost:3000 and hand the response back to the browser. The site itself needs no code changes and no certificate handling.

Caddyfile explained further: how to write multiple services

Running multiple sites on one machine is normal — one block per domain in the Caddyfile. Mind the format: domain followed by a space and a brace, directives inside indented with a tab; Caddy errors out on bad indentation, which actually helps troubleshooting — it won't let you ship a broken config.

blog.example.com {
	reverse_proxy localhost:3000
}

api.example.com {
	reverse_proxy localhost:8000
}

# let Caddy serve static files directly, bypassing the backend
static.example.com {
	root * /var/www/static
	file_server
}

Remember these common directives: reverse_proxy proxies to the backend, file_server serves static files directly, root says where the static files live. And encode gzip turns on compression for text responses — sites load noticeably faster; text-heavy sites like blogs see the biggest effect, with JS/CSS/HTML often shrinking by more than half. After editing config, don't reload in a hurry — run caddy validate --config /etc/caddy/Caddyfile to check syntax first; broken configs never reach production.

sudo caddy validate --config /etc/caddy/Caddyfile  # shows "Valid configuration" if the syntax is OK
sudo systemctl reload caddy

Caddy vs. Nginx: how to choose

One sentence: beginners pick Caddy without thinking; veterans pick Nginx as needed. Caddy's edge is automatic HTTPS and human-readable config — you'll never touch an openssl command in your life. Nginx's edge is the huge ecosystem and plenty of performance-tuning docs, steadier for extreme concurrency. But for personal small sites and side projects, Caddy is plenty with eyes closed. When to switch to Nginx? When you need complex location matching, rate limiting, cache rules, or the team all knows Nginx. Before then, the time Caddy saves you is enough to write several more articles.

Install it: three steps

# 1. install Caddy (Ubuntu/Debian)
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-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

# 2. write the config
sudo nano /etc/caddy/Caddyfile   # paste the two lines above, using your domain

# 3. apply
sudo systemctl reload caddy

After reload, Caddy automatically requests a free certificate from Let's Encrypt — a dozen seconds. Then open https://your-domain.com in a browser; the lock appearing means success. If it's not there after a dozen seconds, don't rush to change config — read the logs first (journalctl -u caddy -n 30); nine times out of ten it's one of the four pitfalls below.

How automatic HTTPS works in one sentence: on startup, Caddy uses the ACME protocol to prove to Let's Encrypt that the domain is yours (by placing a verification file served on port 80), gets a free certificate, and renews it before expiry. You never touch certificate files.

After proxying: why does the backend see 127.0.0.1 as the IP

Behind a reverse proxy, your site sees all requests coming from 127.0.0.1 — because it's actually talking to Caddy, not the browser. If you do visit statistics, rate limiting, or anti-scraping, you need the real IP. The good news: Caddy by default passes the real IP to the backend in the X-Forwarded-For header; your program reads it from there. In Node it's req.headers['x-forwarded-for']; apps behind Nginx are the same. Don't treat the connection IP as the user IP on the backend — that's Caddy's IP; rate-limiting the wrong thing bans all users together.

Where to read the logs: Caddy's access logs

When the site breaks, check two logs: Caddy's own (systemctl status caddy, journalctl -u caddy -n 50) and your backend app's. By default Caddy doesn't keep access logs; add one line “log” in the Caddyfile and access records go into journald:

example.com {
	reverse_proxy localhost:3000
	log {
		output stdout
		format json
	}
}

On a 502, read Caddy's logs first: if Caddy received the request but the backend didn't respond, the log will have a reverse_proxy error — meaning the backend never came up; if Caddy's log shows no request at all, the problem is further up — DNS, firewall, port taken; check pitfalls 1 through 3.

www redirect: should you bother

Many people agonize over keeping example.com or www.example.com. Advice: keep only one, 301-redirect the other — otherwise search engines treat them as two sites and split authority. Two blocks in Caddy do it:

example.com {
	reverse_proxy localhost:3000
}

www.example.com {
	redir https://example.com{uri} permanent
}

{uri} carries the user's path over as-is — www.example.com/about redirects to example.com/about without losing the path. Caddy requests certificates for both domains together; nothing for you to worry about. The search-engine side needs no action — it follows the 301, and authority slowly concentrates on the primary domain.

Do WebSockets need extra config: no

People coming from Nginx ask: does proxying WebSockets need the Upgrade header added? Caddy doesn't — it supports WebSockets by default; ws and wss pass straight through, zero config for chat rooms and realtime notifications. That's one of Caddy's worry-free spots; on Nginx you hand-write proxy_set_header Upgrade $http_upgrade, and one missing line breaks the connection. While we're at it: Caddy also enables HTTP/3 (QUIC) by default — browsers that support it will use it automatically, nothing for you to tune.

Pitfall 1: ports 80/443 are taken

This is the most common error: Caddy fails to start, the log says “address already in use.” Eight times out of ten it's a previously installed Nginx still holding port 80, or a panel like aaPanel or 1Panel that ships its own web service. First find who holds it, then decide who stays — only one program per machine should take 80/443; don't let them fight:

sudo ss -tlnp | grep -E ':80|:443'   # see what is occupying it
# if you decide to use Caddy, stop nginx:
sudo systemctl stop nginx
sudo systemctl disable nginx
sudo systemctl restart caddy

Keep only one program per machine taking port 443 — don't let them fight. One hidden case: IPv6 holds the port while ss only showed IPv4 by default; add -tlnp when checking to see everything, or just read Caddy's log (journalctl -u caddy -n 30) — the error states clearly which address failed to bind.

Pitfall 2: the domain doesn't resolve to this server

Before Caddy requests a certificate it must verify the domain is yours, by looking up the domain's DNS from the public internet. If the domain doesn't resolve to this machine's IP yet, verification always fails, and you'll see acme-related errors in the log. Check first:

dig example.com +short   # swap in your domain and check it resolves to this machine's public IP

If not, add an A record at the domain's DNS provider pointing to the server IP and wait a few minutes. If impatient, dig a few more times; reload Caddy once the IP looks right. Note DNS has caches — you resolving it locally doesn't mean Let's Encrypt resolves it; requesting a certificate right after changing DNS may fail verification. Wait 10 more minutes and try once — don't reload repeatedly. Let's Encrypt rate-limits failures: too many failures within an hour gets you temporarily blacklisted, and then you can only wait out the hour.

Pitfall 3: the firewall didn't open 80 and 443

ACME verification goes through port 80, user visits through 443 — if either is blocked, everything fails. Open both in ufw AND the provider's security group — not just one side:

sudo ufw allow 80
sudo ufw allow 443

The provider's security group (the one on the console webpage) opens 80/443 the same way. After configuring, test from the outside with curl -I http://your-domain.com; configure https only once that works. One easy miss: ufw rule changes take effect immediately (ufw does), but some providers' security groups take tens of seconds — don't test the moment you change; have some water first.

Pitfall 4: mainland China servers need filing first

If you use a mainland Chinese provider (Alibaba Cloud, Tencent Cloud), ports 80 and 443 are blocked at the carrier level by default — no filing, no opening. That's policy, not a technical problem: file the domain with MIIT first (the provider's backend guides you, usually 1–3 weeks); ports open automatically after approval. Hong Kong, Singapore, and US servers have no such restriction — point the domain and go. Decide where your target users are before buying the server, so you don't get stuck on filing after the box is bought.

Verification: three steps to confirm everything works

  • curl -I https://your-domain.com — the first line reads HTTP/2 200
  • Open it in a browser — the address bar shows the lock; click it and the certificate is issued by Let's Encrypt
  • curl -I http://your-domain.com — should 308-redirect to https (Caddy does it by default)

Certificates expire in 90 days; Caddy renews 30 days before expiry, but glance every couple of months anyway: click the lock in the browser to see the validity, or run echo | openssl s_client -connect your-domain.com:443 2>/dev/null | openssl x509 -noout -dates. Nine times out of ten, auto-renewal fails because port 80 got taken later or the DNS was changed — check pitfalls 1 and 2.

Pass all three steps and HTTPS is solid. Certificates last 90 days; Caddy renews them itself before expiry — no cron jobs to set, no midnight certificate swaps. Certificate files live in /var/lib/caddy; you don't need to touch them, but know they're there: if certificates ever break, deleting that directory and restarting Caddy requests fresh ones — the last-resort fix. Also: changing domains or adding subdomains needs no certificate work from you — add a block to the Caddyfile, reload, and certificates follow automatically. What if the site process itself dies? See “systemd Services: Restarting Automatically When They Crash.” If the server is fresh out of the box and uninitialized, read “10 Things to Do First on a New VPS” first. For reverse-proxy commands you can't remember (curl -I, ss -tlnp, dig), this site's shell command cheat sheet (the /shell page) has them all in the network-diagnostics group.

Caddy Reverse Proxy: HTTPS for Your Site in 5 Minutes · EazyOPC Blog