A fresh Ubuntu box, a domain pointed at it, and a directive to “put Caddy on it because HTTPS is too painful with Nginx.” This article is the install procedure we run in production: get the package on from the upstream repository, get the unit running under systemd, open the firewall narrowly, validate a starter Caddyfile, and leave the box in a documented state. The goal is not “Caddy is running” — it is “Caddy is running, observable, certificates are in place, and the box is ready to host a real site.”
How to verify
Before touching the package manager, check what is already there. The box may have an older Caddy v1 binary in /usr/local/bin, or Nginx may be on port 80 and you are about to fight it for the socket.
ss -ltnp | grep -E ':(80|443|2019)\b'
which caddy && caddy version
dpkg -l | grep -E 'caddy|nginx|apache2'
systemctl list-unit-files | grep -E 'caddy|nginx|apache2'
If port 80 is bound, identify the process before installing. Caddy v1 (Go modules era) and Caddy v2 share the binary name but have incompatible configs — confirm the major version if anything is already installed.
What’s happening
Caddy on Ubuntu ships from the Cloudsmith-hosted official repository, not the Ubuntu archive. The archive copy lags by several minor releases and lacks the caddy-api variant. The package installs the caddy binary to /usr/bin/caddy, drops a stub Caddyfile at /etc/caddy/Caddyfile, registers a caddy system user (UID picked from the next free range), creates /var/lib/caddy for the certificate cache, and installs a systemd unit at /lib/systemd/system/caddy.service.
The unit runs as the caddy user. That detail matters: certificates land under /var/lib/caddy/.local/share/caddy/, not /etc/letsencrypt/ like certbot. The process can bind ports under 1024 because the systemd unit grants CAP_NET_BIND_SERVICE, not because it runs as root. Reload on config change is via systemctl reload caddy, which sends SIGUSR1 — Caddy reloads the config in-process with zero dropped connections.
Two binaries exist in the upstream repo:
caddy— the standard binary with the static modules baked in at release time.caddy-api— an alternate systemd unit that boots from/etc/caddy/caddy.json(JSON config) instead of/etc/caddy/Caddyfileand exposes the admin API on127.0.0.1:2019.
For most hosts you want the standard caddy package. For dynamic, API-driven setups (containers writing config at runtime), switch to caddy-api.
The procedure
-
Add the official repository. The upstream signing key and apt source come from Cloudsmith:
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 -
Install the binary. Standard package for static config;
caddy-apifor runtime-driven config:sudo apt install -y caddy caddy version -
Confirm the service is up under systemd. The package starts and enables the unit automatically; verify both:
systemctl is-active caddy systemctl is-enabled caddy sudo systemctl status caddy --no-pager -
Open the firewall narrowly. Caddy needs 80 (HTTP redirect and ACME HTTP-01 challenge) and 443 (HTTPS plus HTTP/3 over UDP/443). If you use UFW:
sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 443/udp sudo ufw reload sudo ufw status verboseThe UDP rule is mandatory for HTTP/3 (QUIC). Skip it and clients silently fall back to HTTP/2 over TCP — slower, but not broken.
-
Replace the stub Caddyfile. The default file at
/etc/caddy/Caddyfileserves the “Caddy works!” splash page on:80. Replace it with a real site block. Minimum production starter:{ email [email protected] servers { metrics } } example.com { root * /var/www/example.com file_server encode zstd gzip log { output file /var/log/caddy/access.log { roll_size 100MiB roll_keep 14 } format json } }The global
emaildirective is the ACME account email — without it, Let’s Encrypt registrations fall back to a placeholder address and you lose expiry notifications. -
Validate before reload.
caddy validateparses the Caddyfile, expands snippets and imports, and returns non-zero on syntax errors:sudo caddy validate --config /etc/caddy/Caddyfile sudo systemctl reload caddyA reload that fails leaves the previous config running — no downtime — but
journalctl -u caddyis your only signal. Watch it. -
Confirm HTTPS issued. From outside the box (cert provisioning must reach the public network):
curl -I https://example.com/ openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | openssl x509 -noout -issuer -datesIssuer should read
Let's Encrypt. Notice the cert is in/var/lib/caddy/.local/share/caddy/certificates/— back that directory up, or be prepared to re-issue on a host rebuild.
Common pitfalls
- The Ubuntu archive
caddypackage is too old; never install viaapt install caddywithout first adding the Cloudsmith repo. - Running
caddy runinteractively as root works for testing but writes certificates to/root/.local/share/caddy/— the systemd-managed location is/var/lib/caddy/.local/share/caddy/; the two installs do not share state. - If port 443 is blocked at the security group, Caddy retries the ACME challenge forever;
journalctl -u caddy | grep -i acmeshows the rate-limit warnings before Let’s Encrypt blackholes you. - The
caddy reloadsubcommand differs fromsystemctl reload caddy— both work, but only the systemd path is captured in the audit log ofjournalctl. - IPv6: if AAAA records exist and the box has no v6 routing, ACME picks one address per attempt and retries — half your challenges silently fail. Either remove the AAAA or fix v6 egress.
Stack Harbor runs Caddy as the default edge for clients who do not need Nginx’s module ecosystem — install scripted, Caddyfiles under config management, certificate state backed up off-box, and the access logs streaming into the same pipeline as the rest of the stack. If you have a Caddy fleet that grew organically and you want it consolidated under one operating playbook, that is the work covered by our Managed Operations practice.