Serve the site with Caddy and HTTPS

Caddy installed from its official repository, serving a placeholder page from the web root over HTTPS with a Let's Encrypt certificate it renews by itself. www and plain HTTP redirect to the bare domain (nothing in front), HTTP/3 is on, security headers and caching are set, access logs are rotated.

intermediate~20 min hands-on
#caddy#https#lets-encrypt#web-server#debian

Not validated end to end yet — be the first.Report a problem

Draft — not yet run end to end. This page was written but its author has not yet run it on a real machine. Commands may be wrong: read before you run, and tell us what breaks.

The gistOne web server, one certificate, one link
Your VPS
HTTPSwww → 301 → servesloadsACME
Visitorhttps://
Let's EncryptACME
CaddyHTTPS · HTTP/3
Caddyfile/etc/caddy/Caddyfile
current/current

Visitors reach Caddy on 443 (and 80, which only redirects). Caddy gets and renews its certificate from Let's Encrypt on its own, and serves whatever release the current link points at.

The server is locked down and the domain points at it. This page puts a web server in front: it opens the web ports, installs Caddy from its official repository, creates the web root with a placeholder page, writes one Caddyfile, and checks from your laptop that https:// answers with a valid certificate. The next page replaces the placeholder with the real site.

Before you start

Check
$dig +short A 
Expected output

Open the web ports

Server·
$sudo ufw allow 80/tcp
$sudo ufw allow 443/tcp
$sudo ufw allow 443/udp

Install Caddy

Server·
$sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
$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 -y caddy
Check
$systemctl is-active caddy
Expected output
active

Create the web root and a placeholder

Server·
$sudo install -d -m 755 /releases/placeholder
$echo '<!doctype html><meta charset="utf-8"><title></title><p> is being set up. Back soon.</p>' | sudo tee /releases/placeholder/index.html
$echo '<!doctype html><meta charset="utf-8"><title>Not found</title><p>404: nothing here.</p>' | sudo tee /releases/placeholder/404.html
$sudo ln -sfn releases/placeholder /current

Write the Caddyfile

The block below is the whole of /etc/caddy/Caddyfile, already set for the two choices of this page, HSTS and IP masking in the logs: change them in the panel and the file follows. Copy the command and paste it in the terminal. It replaces the default file in one go; there is nothing to add to it afterwards.

Server·writes a file/etc/caddy/Caddyfile
{
email
}
www. {
redir https://{uri} permanent
}
{
root * /current
encode zstd gzip
file_server
handle_errors 404 {
rewrite * /404.html
file_server
}
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
@static path /_next/static/*
header @static Cache-Control "public, max-age=31536000, immutable"
@media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.mp3 *.opus *.wav *.mp4
header @media Cache-Control "public, max-age=604800"
header Strict-Transport-Security "max-age=86400"
log {
output file /var/log/caddy/access.log {
roll_size 10MiB
roll_keep 10
}
format filter {
request>remote_ip ip_mask 16 32
request>client_ip ip_mask 16 32
}
}
}

HSTS. The Strict-Transport-Security line of the file follows your choice.

Validate and reload

Server·
$sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
$sudo systemctl reload caddy
$sudo journalctl -u caddy -f
If the certificate fails

Read the error line in the journal first; it names the cause. These lookups, from your laptop, cover the DNS side. By likelihood:

Mac
$dig +short A
$dig +short A www.
$dig +short AAAA
$dig +short CAA
  • DNS: both A lookups must answer . Right after a change, wait for the old TTL to expire.
  • Wrong AAAA: if the AAAA lookup answers anything, it must be . Let's Encrypt tries IPv6 first, so a stale AAAA fails validation even when IPv4 is perfect. No AAAA at all is fine.
  • Port 80 closed: sudo ufw status must show 80/tcp ALLOW; if OVH's Network Firewall is on, open 80 there too.
  • CAA: the CAA lookup must be empty or contain letsencrypt.org. When Let's Encrypt fails, Caddy falls back to ZeroSSL, whose CAA identifier is sectigo.com.
  • Rate limits: Let's Encrypt allows 5 failed validations per hostname per hour. While you debug, point Caddy at the staging CA: open the file by hand on the server,
Server·
$sudo vim /etc/caddy/Caddyfile

and add this line inside the global options block at the top, under email, then validate and reload as above:

text
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory

Staging certificates are not trusted by browsers, so curl will complain; that is expected. Once the journal shows success, paste the Caddyfile command of the previous step again (it rewrites the file without the line), then validate and reload: Caddy stores certificates per CA and requests a real one.

Check it from your laptop

Check
$curl -sI https:/// | head -1
Expected output
HTTP/2 200
Check
$curl -sI https://www./ | head -1
Expected output
HTTP/2 301
Check
$curl -sI https://www./ | grep -i '^location'
Expected output
location: https:///
Check
$curl -s -o /dev/null -w '%{http_code}' https:///nope/
Expected output
404
Check
$curl -sI https:/// | grep -ci '^strict-transport-security'
Expected output
1

Done

now answers over HTTPS with a Let's Encrypt certificate that Caddy renews on its own, well before expiry. www and plain HTTP redirect to https://, HTTP/3 is available, static assets are cached hard, and the access log rotates itself with masked addresses. Visitors see the placeholder.

The next page, Deploy atomic releases with rsync, builds the Next.js site on your laptop, uploads it as a new release under , and swaps the current link to put it live.

Did everything work?

If you followed this page to the end on a real machine, say so. Your validation is dated and records your stack, so the next reader on the same path knows it still works.

This copy is read-only. To report that it works, or that it does not, open an issue

Only your stack choices are recorded, never your values. The pseudonym stays on this browser.