Nginx plus Certbot plus a renewal cron job is three moving parts where one would do. Caddy issues and renews TLS certificates on its own, and its config is a few lines. Here is the Ubuntu install, a Caddyfile for a static site and a reverse proxy, and how ACME works under the hood.
The whole point of a Caddy web server setup is that there is almost nothing to set up. One apt repository, three or four lines of config, and zero certificate plumbing. Caddy fetches a TLS certificate as soon as the domain is in its active config, renews it early on its own, and works as a competent reverse proxy. If you run a small site or an app on a VPS and you are tired of Nginx plus Certbot plus a renewal cron job, this walks through installing Caddy on Ubuntu 22.04, 24.04 and 26.04 and writing your first config for a static site and for a proxy to an app.
TL;DR. Add Caddy's official apt repository (hosted on Cloudsmith), install the
caddypackage, and you get a systemd service with it. Describe your site in/etc/caddy/Caddyfile:rootandfile_serverfor static files,reverse_proxy localhost:3000for an app. Check the config withcaddy validate, apply it withsystemctl reload caddy. Point the domain's A and/or AAAA record at the server and keep at least one of ports 80 and 443 reachable: Caddy then issues a publicly-trusted certificate by itself, usually via Let's Encrypt with ZeroSSL as a fallback. Certificates and keys live in/var/lib/caddy/.local/share/caddy, logs go tojournalctl -u caddy.
Caddy is a web server written in Go, the same category as Nginx or Apache: it serves static files, proxies requests to applications, and terminates HTTPS. The difference is that HTTPS is on by default. Certbot is the Let's Encrypt client you install separately alongside Nginx, wiring up a plugin or a webroot and adding a system timer for renewals. Caddy does all of that internally. As soon as the domain is in the active config, Caddy talks to a certificate authority in the background, passes an ownership check, stores the certificate in memory and on disk, then renews it early (roughly a third of the lifetime before expiry). It does not wait for a first user request. No extra daemon, no cron entry, no reload step.
The config gap is just as visible. An Nginx server block for an HTTPS reverse proxy is 15 to 20 lines: listen 443 ssl, ssl_certificate, a stack of proxy_set_header directives. The Caddyfile equivalent is two lines. Caddy does not replace Nginx everywhere (there is a section on that below), but for the common case of one to three sites on a VPS that need HTTPS and a backend proxy, it removes almost all of the routine.
Caddy listens on TCP ports 80 and 443, but it does not touch the firewall itself. If those ports are allowed inbound, the server becomes reachable from the internet, so the machine is exposed. On a freshly created VPS, close unused ports and switch to key-based login first. That is a separate topic (see "Securing a fresh VPS"). From here on the guide assumes you have root or a sudo user and that the firewall allows inbound TCP on 80 and 443 (plus UDP 443 if you want HTTP/3).
Install from the project's official repository, which is hosted on Cloudsmith. Ubuntu 24.04 and 26.04 ship a caddy package, but a much older branch; Ubuntu 22.04 has no Caddy package in its default repository at all. The Caddy repo gives you the current stable release and future updates through a normal apt update. The first block installs dependencies, adds the repository's signing key, and writes its address.
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 chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
debian-keyring, debian-archive-keyring: key sets apt uses to verify repository signatures.gpg --dearmor -o ...caddy-stable-archive-keyring.gpg: puts Caddy's key where apt expects it.curl writes the repository line into /etc/apt/sources.list.d/; tee is there because the write needs root.chmod o+r commands make the key and the list file readable by apt's sandbox user (_apt). On hardened images with a strict umask, apt update fails with Permission denied without them.Now refresh the package index and install.
sudo apt update
sudo apt install caddy
What you should see. apt update lists the Caddy repository line (...cloudsmith.io/public/caddy/stable...) with no GPG errors. If apt complains about an expired key (EXPKEYSIG), remove the stale keyring and write it again: sudo rm /usr/share/keyrings/caddy-stable-archive-keyring.gpg, then re-run the curl ... gpg.key line. The repo key gets rotated periodically. After apt install caddy, the package creates a caddy system user, installs the binary at /usr/bin/caddy, drops a starter config at /etc/caddy/Caddyfile, and starts a systemd service named caddy. systemd is the standard service manager on Linux: it starts the process at boot and restarts it if it crashes.
Confirm the install.
caddy version
systemctl status caddy
What you should see. caddy version prints something like v2.11.4 h1:.... systemctl status caddy shows a green active (running). Open http://your-server-ip in a browser and Caddy serves a placeholder page linking to its docs, which means the service is alive. That starter config only serves the placeholder on port 80; you replace it next.
When a site address in the Caddyfile is a hostname (not an IP, not localhost), Caddy turns on HTTPS by itself. The mechanism is ACME (Automatic Certificate Management Environment), the standard certificate-issuance protocol that Certbot also uses. By default Caddy tries two certificate authorities in order: Let's Encrypt first, then ZeroSSL. Caddy starts certificate management right after it loads the config and runs it in the background, so it never waits for a first user request.
To issue a certificate, the CA has to confirm the domain is yours. Caddy answers one of two challenges: HTTP-01 on port 80 or TLS-ALPN-01 on port 443, so it is enough for either one of those ports to be reachable from outside, with the domain's A and/or AAAA record pointing at this server. If both ports are firewalled or already taken by Nginx, the check fails and the log shows an ACME error. You can drop the inbound 80/443 requirement with the DNS-01 challenge (it needs a provider plugin and a binary rebuilt with xcaddy), which needs no open ports at all.
localhost and internal names normally do not use a publicly-trusted certificate. For those, and by default for IP addresses and anything with an explicit tls internal, Caddy runs its own local certificate authority and signs the certificate itself (a publicly-trusted certificate for an IP is possible with some CAs, but it is not the default path). Browsers will not trust that certificate until you add Caddy's root certificate to your trust stores; the root lives at /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt. For internal services and local development that is enough.
The caddy service runs as the caddy user with home /var/lib/caddy. Certificates, private keys, and ACME state live in /var/lib/caddy/.local/share/caddy (with a certificates subdirectory). The auto-saved JSON config sits in /var/lib/caddy/.config/caddy. While the service runs normally, leave these files alone: renewal is automatic.
The Caddyfile is Caddy's main config, at /etc/caddy/Caddyfile by default. The syntax is short: a line with the site address, then directives inside braces. Replace the starter config with a static file server. Put your site files somewhere first (for example /var/www/example.com) and confirm example.com has an A and/or AAAA record pointing at the server (drop any stale AAAA that points elsewhere, or ACME behaves oddly). Make sure the caddy user can read those files and traverse the directories (read permission, plus execute on the directories), otherwise you get a 403 instead of the page. Make /etc/caddy/Caddyfile look like this:
example.com {
root * /var/www/example.com
file_server
encode zstd gzip
}
example.com: the site address. A bare domain with no http:// tells Caddy to enable HTTPS.root * /var/www/example.com: the site root; * means "for every request path".file_server: serve files from disk.encode zstd gzip: compress responses (zstd, falling back to gzip). Optional, but almost always worth it.Check the config before applying it. caddy fmt normalises indentation; caddy validate parses the config and tells you whether the syntax is valid and where it breaks. Run both before every reload.
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
What you should see. On success caddy validate prints Valid configuration. On failure it names the line and the reason: fix it and run again. Then apply without stopping the service:
sudo systemctl reload caddy
The command returns silently with no output, which is expected. After a few seconds, check the result:
curl -I https://example.com
journalctl -u caddy -n 30 --no-pager
What you should see. curl returns a successful HTTP response with no TLS error; the exact status (200, 301, ...) and protocol version depend on the site and the curl build. The log shows a certificate obtained successfully line for example.com. If the certificate is still being issued, repeat the curl after 30 seconds. An SSL certificate problem error in the first few seconds after a reload is usually just a race: wait and retry.
A reverse proxy is a server that accepts requests from the outside and forwards them to an application inside the machine, returning the response to the client. The address the proxy forwards to is called the upstream. You need this when your Node.js, Python, or Go service listens on localhost:3000 and you want to expose it over HTTPS on 443. In a Caddyfile:
app.example.com {
reverse_proxy localhost:3000
}
app.example.com: a subdomain with an A record on the same server.reverse_proxy localhost:3000: forward requests to the app at that address. For a plain-HTTP upstream, Caddy passes the client's original Host header through unchanged and adds X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, so the app sees the real client address, protocol and host. (For contrast: nginx's proxy_pass rewrites Host to the upstream by default - Caddy does not.)Several sites in one file is just several blocks in a row, static and proxy mixed freely. After editing, run caddy validate and systemctl reload caddy again, then check:
curl -I https://app.example.com
What you should see. A successful response, or whatever status the app itself returns (200, 302, ...). A 502 Bad Gateway means the proxy works but the upstream is unreachable: check that the service is listening on port 3000.
sudo ss -tlnp | grep 3000
Empty output means nothing is listening on 3000: start the app or fix the port in the config.
reload applies a new config without stopping the service and without dropping connections: Caddy brings up the new configuration alongside the old one and switches over. Use it for every Caddyfile change. restart stops and starts the process, so it causes a short outage; you only need it if the service is hung or you edited the unit file itself (then run sudo systemctl daemon-reload first). Under the hood, systemctl reload caddy runs caddy reload --config /etc/caddy/Caddyfile --force.
Item | Location |
|---|---|
Main config | |
Binary | |
Certificates, keys, ACME data | |
Auto-saved JSON config | |
systemd unit | |
Logs | |
journalctl -u caddy holds Caddy's service logs: start-up, certificate issuance and renewal, errors. Request (access) logs are off by default; turn them on with the log directive in the Caddyfile.
Aspect | Caddy | Nginx |
|---|---|---|
TLS certificates | issues and renews on its own | separate Certbot plus a system timer |
HTTPS proxy config | 2 to 3 lines | 15 to 20 lines |
Config format | Caddyfile or JSON via API | its own syntax |
Applying changes | reload, no downtime | reload, no downtime |
HTTP/3 (QUIC) | on by default | since 1.25, via a directive |
Extensions | plugins, rebuild with xcaddy | modules, often a rebuild; more available |
Static files under heavy load | good | traditionally a bit faster, finer tuning |
Ecosystem and examples | smaller | huge, nearly every case already written up |
Stay on Nginx if: you already have a large working config and no reason to port it; you need fine TLS control (specific ciphers and versions, unusual client-certificate setups); routing and rewrite logic is complex and reads more naturally in Nginx; you need its modules (RTMP, the wider Lua/OpenResty world); or formal support and being in Ubuntu's main repository matter. Otherwise Caddy saves time: HTTPS and renewal stop being a task, the whole config reads in a minute, and a reverse proxy is one line.
The service itself runs as the caddy system user, but you need sudo to install it and to edit the config under /etc/caddy. Permission to bind ports 80 and 443 is granted to the process through a capability in the unit file, so no extra step. For the local certificate authority, Caddy tries to add its root certificate to the system trust store; that may fail under the service user, in which case add it manually from the data directory.
Yes. In the site block, add tls /path/fullchain.pem /path/privkey.pem and Caddy uses your files instead of ACME. This is the path when the certificate comes from a corporate CA or a commercial vendor.
The HTTP-01 challenge fails. Caddy then tries TLS-ALPN-01 on 443; if that is also blocked, no certificate is issued and the log shows an ACME error. Open both ports, or switch to a DNS challenge, which needs a provider-specific plugin and a binary rebuilt with xcaddy.
Yes. HTTP/3 over QUIC (UDP 443) has been on by default since Caddy 2.6. Clients that cannot do it fall back to HTTP/2. Make sure the firewall passes inbound UDP on 443, otherwise some clients silently drop to HTTP/2.
Run journalctl -u caddy | grep -i certificate and look for certificate obtained successfully. Files appear under /var/lib/caddy/.local/share/caddy/certificates. Faster still: curl -vI https://your-domain and read the TLS handshake and issuer lines.
For a handful of sites, tens of megabytes of memory at idle; a small VPS (1 vCPU, 1 GB RAM) has plenty of headroom. Exact numbers depend on traffic and concurrent connections, so measure on your own load rather than trusting someone else's figures.
Yes, but not on the same ports. Either Caddy takes 80 and 443 while Nginx listens on localhost as an upstream, or you split them across different ports. They cannot both bind 443: the second process fails to start.
caddy package brings a systemd service, a starter /etc/caddy/Caddyfile, and a caddy system user.root plus file_server. Reverse proxy: reverse_proxy localhost:3000.caddy validate, then systemctl reload caddy (no downtime)./var/lib/caddy/.local/share/caddy; logs are in journalctl -u caddy.