Back to tutorials
Tutorial

Set Up Let’s Encrypt With Caddy for a VPS Site

Caddy Let's Encrypt setup on a VPS: install Caddy, open ports, configure DNS, verify auto-renewal, and fix common ACME errors.

By Anurag Singh
Updated on Nov 01, 2026
Category: Tutorial
Share article
Set Up Let’s Encrypt With Caddy for a VPS Site

Caddy makes HTTPS the default instead of a chore. With correct DNS and open ports, it will request and renew Let’s Encrypt certificates automatically. It can also reload your config without dropping connections.

This guide shows a production-ready Caddy Let's Encrypt setup on a real VPS. You’ll get verification steps and an ACME troubleshooting section you’ll actually use.

What you’ll build (and what you need)

You’ll serve a public site through Caddy on ports 80/443. Behind Caddy, you can run a static directory or an upstream app (Node, PHP-FPM via another web server, a Docker container, and so on).

Caddy will handle certificate issuance and renewals via ACME with Let’s Encrypt.

  • Server: Ubuntu 24.04/26.04 LTS, Debian 12/13, AlmaLinux 9/10, or Rocky Linux 9/10
  • Access: root or sudo SSH access
  • Domain: example.com and (optionally) www.example.com
  • DNS: an A/AAAA record pointing to your VPS public IP
  • Ports: 80 and 443 reachable from the internet

If you don’t have a server yet, start with a HostMyCode VPS. If you’d rather offload OS updates, security baselines, and monitoring, managed VPS hosting gets you to a stable setup faster.

Step 1: Point DNS to your VPS (and confirm it’s correct)

Before Caddy can pass an HTTP-01 challenge, Let’s Encrypt must resolve your domain to this VPS. It also must connect over port 80.

Set DNS first.

  1. Create an A record for example.com → your IPv4 address.
  2. (Optional) Create an AAAA record for IPv6 if your VPS has one.
  3. (Optional) Add www as a CNAME to example.com or as its own A/AAAA.

Verify from your laptop (or any machine with dig):

dig +short A example.com
dig +short AAAA example.com
dig +short A www.example.com

You should see the VPS IP(s). If you recently changed DNS, allow time for propagation. Also confirm you didn’t leave an old A/AAAA record in place.

If you’re moving DNS providers and want to avoid downtime, this HostMyCode guide is a solid checklist: migrate a domain to Cloudflare DNS without downtime.

Step 2: Open firewall ports 80/443 (UFW or firewalld)

Caddy needs inbound HTTP and HTTPS. If port 80 is blocked, HTTP-01 fails. If port 443 is blocked, the site won’t load over TLS.

Ubuntu/Debian (UFW)

sudo ufw status verbose
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload

Confirm rules:

sudo ufw status

If you haven’t set up a baseline firewall yet, follow: Set up UFW for a web server.

AlmaLinux/Rocky (firewalld)

sudo firewall-cmd --state
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Confirm:

sudo firewall-cmd --list-services

Step 3: Install Caddy (from the official repository)

Install from the upstream repo so you get timely security updates and a systemd unit. Distro repositories often lag behind.

Ubuntu/Debian

sudo apt update
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 apt update
sudo apt install -y caddy

AlmaLinux/Rocky

sudo dnf install -y 'dnf-command(copr)'
sudo dnf copr enable -y @caddy/caddy
sudo dnf install -y caddy

Start and enable Caddy:

sudo systemctl enable --now caddy
sudo systemctl status caddy --no-pager

Quick sanity check: many packages ship with a default config that serves a placeholder page. Test locally on the VPS:

curl -I http://localhost

Step 4: Create a minimal Caddyfile for automatic HTTPS

Caddy’s main config usually lives at /etc/caddy/Caddyfile. The systemd service runs as the caddy user.

By default, it expects that path.

Open the file:

sudo nano /etc/caddy/Caddyfile

Replace the contents with this baseline (edit the domain):

{
	# Set a real email so Let’s Encrypt can contact you about expiring certs.
	email admin@example.com
}

example.com, www.example.com {
	encode zstd gzip

	# Serve a simple static site as a baseline.
	root * /var/www/example.com
	file_server

	# Security headers you can keep even for static sites.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Access log (useful while you validate ACME and routing).
	log {
		output file /var/log/caddy/example.com.access.log
	}
}

Create the web root and a test page:

sudo mkdir -p /var/www/example.com
printf '%s\n' '<!doctype html><h1>Caddy is serving this site over HTTPS</h1>' | sudo tee /var/www/example.com/index.html

Create the log directory with correct ownership:

sudo mkdir -p /var/log/caddy
sudo chown -R caddy:caddy /var/log/caddy

Validate and reload Caddy (don’t skip validation):

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -n 100 --no-pager

Step 5: Verify the certificate and HTTPS redirect

Test from your laptop. HTTP should redirect to HTTPS, and the cert should be issued by Let’s Encrypt.

curl -I http://example.com
curl -I https://example.com

Look for:

  • HTTP/1.1 308 Permanent Redirect from HTTP to HTTPS
  • server: Caddy on the HTTPS response (unless you removed it via headers)

Inspect the certificate chain:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -issuer -subject -dates

If you’re fronting an upstream app, confirm routing works while things are still simple. For reverse-proxy patterns and routing, this companion guide helps: set up Caddy as a reverse proxy for Docker.

Step 6: (Common) Reverse proxy to an application on localhost

Static files are great for validation, but most VPS sites run an app. In this pattern, Caddy terminates TLS. It then proxies to an app on 127.0.0.1:3000.

Edit /etc/caddy/Caddyfile and replace the root/file_server block with:

example.com, www.example.com {
	encode zstd gzip

	reverse_proxy 127.0.0.1:3000 {
		# Good defaults for apps that care about original scheme/host
		header_up X-Forwarded-Proto {scheme}
		header_up X-Forwarded-Host {host}
		header_up X-Forwarded-For {remote}
	}

	log {
		output file /var/log/caddy/example.com.access.log
	}
}

Reload and verify:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://example.com

Common mistake: pointing Caddy at a container-only DNS name (like app:3000) while Caddy runs on the host. If you’re using Docker Compose, run Caddy in the same Compose network. Or proxy to 127.0.0.1 after publishing the app port.

Step 7: Confirm automatic renewal actually works

Let’s Encrypt certificates are short-lived by design. Caddy renews them automatically.

Still, confirm the basics: where certs live, what “healthy” looks like, and how you’ll spot renewal failures.

Check where Caddy stores certs

On most Linux installs, Caddy stores certs under /var/lib/caddy (its data directory).

sudo ls -la /var/lib/caddy
sudo find /var/lib/caddy -maxdepth 4 -type f -name '*.crt' -o -name '*.pem' | head

Check the systemd timer or service behavior

Depending on packaging, renewal happens inside the running Caddy process. It renews ahead of expiry.

Practical checks are simple: Caddy stays running, it can write to its data directory, and it can reach Let’s Encrypt endpoints.

sudo systemctl is-active caddy
sudo systemctl cat caddy | sed -n '1,120p'

Force a config reload and read logs

A reload won’t force renewal. It does confirm Caddy can parse the config and access stored certificate material.

sudo systemctl reload caddy
sudo journalctl -u caddy -n 200 --no-pager

For a simple “renewal confidence” check, set a calendar reminder and re-run:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates

You should see notAfter move forward over time without manual work.

Step 8: Troubleshoot ACME failures (fast diagnostics that actually work)

Most ACME failures come from DNS, blocked ports, or another service already bound to 80/443. Start here and work in order.

1) Confirm the domain resolves to this server

dig +short A example.com
curl -4s https://ifconfig.me; echo
# If you have IPv6:
curl -6s https://ifconfig.me; echo

If dig returns an IP that isn’t your server, fix DNS first. If you publish both A and AAAA but IPv6 is broken, Let’s Encrypt may try AAAA and fail.

Either fix IPv6 routing or remove the AAAA record until you’re ready.

2) Check that ports 80 and 443 are reachable from the internet

On the VPS, confirm Caddy is listening:

sudo ss -lntp | egrep ':(80|443)\b' || true
sudo systemctl status caddy --no-pager

If something else is on the port (Apache, Nginx, another proxy), you’ll see it here. Only one service can bind to 80/443.

From outside the VPS (your laptop), test connectivity:

curl -I http://example.com
curl -I https://example.com

Common mistake: opening a cloud firewall but forgetting the OS firewall (or the other way around). Both layers need to allow 80/443.

3) Look for the exact ACME error in logs

sudo journalctl -u caddy -n 300 --no-pager | sed -n '1,300p'

Typical messages and what they mean:

  • “timeout during connect”: port 80/443 blocked or routing issue
  • “no valid A/AAAA records found”: DNS missing or wrong hostname
  • “challenge failed” with unexpected content: another server answered on port 80
  • rate limit: too many failed attempts; wait, then fix root cause

4) ACME fails only for www (or only for apex)

This is almost always DNS mismatch. Confirm both names resolve and both point to the same VPS:

dig +short A example.com
dig +short A www.example.com

If you only want one hostname, keep it simple. Leave just example.com in the Caddyfile. You can redirect www at DNS or in Caddy later.

5) You’re behind Cloudflare or another proxy

If Cloudflare proxy (orange cloud) is enabled before Caddy has a valid certificate, you can chase the wrong problem. For first issuance, it’s often easier to set DNS to “DNS only”.

That way, Let’s Encrypt validates against your origin directly. Re-enable the proxy once HTTPS works end-to-end.

Cloudflare-specific gotchas (still common in 2026):

  • Use “Full (strict)” mode once Caddy is serving a valid cert.
  • Make sure Cloudflare isn’t rewriting HTTP → HTTPS in a way that breaks the HTTP-01 challenge path.

Step 9: Harden the setup (small changes with outsized payoff)

Caddy is secure by default. Production failures are usually boring: unpatched packages, exposed admin surfaces, and no visibility when something breaks.

A few small habits keep HTTPS automation reliable.

Enable unattended security updates (Ubuntu/Debian)

If your VPS isn’t managed, automate security patches at minimum:

sudo apt update
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

This HostMyCode tutorial walks through a clean baseline, including verification: set up automated security updates.

Keep Caddy logs readable and rotated

If you write logs to disk, rotate them. On many distros, logrotate is already installed.

Create:

sudo tee /etc/logrotate.d/caddy >/dev/null <<'EOF'
/var/log/caddy/*.log {
  daily
  rotate 14
  compress
  missingok
  notifempty
  copytruncate
}
EOF

Test the policy:

sudo logrotate -d /etc/logrotate.d/caddy | head -n 50

Add monitoring so you notice outages before users do

At minimum, monitor HTTP uptime and certificate validity. A straightforward self-hosted option is Uptime Kuma.

HostMyCode shows a deployment pattern you can adapt: self-host Uptime Kuma.

Step 10: Backups you can restore (Caddy config + site/app data)

You can always re-issue certificates. Your config and application data are a different story.

Back up at least:

  • /etc/caddy/Caddyfile (and any included config)
  • /var/www/ (if you serve static content)
  • Your app directory, environment files, and Compose files (if used)
  • /var/lib/caddy (optional; includes cert storage and state)

Create a quick tar backup (useful for small sites and config snapshots):

sudo mkdir -p /root/backups
sudo tar -czf /root/backups/caddy-snapshot-$(date +%F).tar.gz \
  /etc/caddy /var/www /var/lib/caddy 2>/dev/null || true
sudo ls -lh /root/backups | tail

Restore test (don’t skip): on a staging VPS or a fresh VM, untar and reload Caddy:

sudo tar -xzf caddy-snapshot-YYYY-MM-DD.tar.gz -C /
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy

If you also run MariaDB/MySQL, you need database backups too. This existing guide is a solid baseline: install MariaDB and create secure backups.

If you want Caddy + Let’s Encrypt to stay boring (in the best way), run it on a VPS with predictable networking and storage. Start with a HostMyCode VPS, or choose managed VPS hosting if you’d rather hand off patching and baseline hardening.

FAQ: Caddy + Let’s Encrypt on a VPS

Do I still need port 80 open if I only want HTTPS?

Usually, yes. Let’s Encrypt HTTP-01 validation requires inbound port 80. DNS-01 is an alternative, but it needs API access to your DNS provider and a different configuration.

Why does ACME fail when I have both A and AAAA records?

If your IPv6 path is blocked or misrouted, Let’s Encrypt may try validation over IPv6 and fail. Fix IPv6 end-to-end, or remove the AAAA record until you can support it reliably.

Where does Caddy store certificates on Linux?

Commonly under /var/lib/caddy. The exact location depends on package defaults, but the data directory is the first place to check.

Can I run Caddy alongside Apache or Nginx?

Not on the same IP/ports 80 and 443 at the same time. If you want to keep Apache/Nginx for app handling, run them on an internal port (for example 8080) and have Caddy reverse proxy to them.

How do I know renewals are happening?

Check certificate dates with openssl s_client and watch journalctl -u caddy for renewal events. The notAfter date should move forward over time without manual intervention.

Summary: a reliable Caddy Let's Encrypt setup

This setup works when the basics are right. DNS must point to the VPS. Ports 80/443 must be reachable. Install Caddy from the official repo, and keep your Caddyfile minimal until issuance succeeds.

After you confirm the redirect and certificate chain, Caddy’s renewal automation usually stays out of your way.

If you’re deploying this for multiple sites or customer projects, a right-sized HostMyCode VPS gives you the control Caddy expects, without the overhead of a full dedicated server.