Caddy is a web server written in Go whose best-known feature is automatic HTTPS: when a site in its configuration has a public domain name, Caddy obtains a certificate from Let's Encrypt or ZeroSSL, renews it before it expires, and redirects HTTP to HTTPS without any extra configuration. Its configuration file, the Caddyfile, is short and readable. In this tutorial you will install Caddy on Ubuntu 24.04 from the official repository, serve a static website over HTTPS, reverse proxy a backend application, and run a PHP site through PHP-FPM.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has
sudoprivileges. - A domain name with a DNS A record (and AAAA if you use IPv6) for
your_domainpointing to your server's public IP. Step 6 also usesapp.your_domain, and Step 7 usesphp.your_domain. - Ports 80 and 443 free on the server. Stop and disable Nginx or Apache if they are installed.
Step 1 - Installing Caddy
Caddy's packages are hosted on Cloudsmith. Install the tools needed to add the repository:
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
Import the repository signing key:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
Add the repository. The list file published by Caddy already references the keyring you just created:
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 /etc/apt/sources.list.d/caddy-stable.list
Install Caddy:
sudo apt update
sudo apt install -y caddy
The package creates a caddy system user, installs a systemd service that starts immediately, and places a default configuration in /etc/caddy/Caddyfile. Check the version and the service status:
caddy version
systemctl status caddy --no-pager
v2.11.4 h1:...
● caddy.service - Caddy
Loaded: loaded (/usr/lib/systemd/system/caddy.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-09-25 10:02:11 UTC; 15s ago
Your version will probably be newer. The default configuration serves a welcome page on port 80. Test it from the server:
curl -s http://localhost | grep -o '<title>.*</title>'
<title>Caddy works!</title>
Step 2 - Opening the firewall
Caddy needs port 80 for the ACME HTTP challenge and redirects, and 443 for HTTPS. Allow both with UFW, together with SSH:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
If you want HTTP/3, which Caddy enables by default, also allow UDP on 443:
sudo ufw allow 443/udp
Verify the rules:
sudo ufw status
Step 3 - Understanding the Caddyfile
The Caddyfile is made of site blocks. Each one starts with one or more addresses followed by directives in braces:
your_domain {
root * /var/www/your_domain
file_server
}
The address decides how the site is served:
- A domain name such as
your_domainenables automatic HTTPS: Caddy listens on 443, gets a certificate, and redirects port 80 to HTTPS. - A port such as
:80orhttp://your_domainserves plain HTTP with no certificate. localhostor an internal name gets a certificate from Caddy's own local CA, useful for development.
Directives such as root, file_server, reverse_proxy and encode are applied in a fixed, sensible order, regardless of the order you write them. Two commands help catch mistakes before they reach production:
sudo caddy fmt --overwrite /etc/caddy/Caddyfilenormalizes indentation.sudo -u caddy caddy validate --config /etc/caddy/Caddyfileparses the file and reports errors without touching the running server. Running it as thecaddyuser checks the configuration with the same permissions as the service, for example write access to log files.
Step 4 - Serving a static website with HTTPS
Create a directory for the site with a test page. Files must be readable by the caddy user; the default permissions for new directories under /var/www allow that:
sudo mkdir -p /var/www/your_domain
echo '<h1>Hello from Caddy</h1>' | sudo tee /var/www/your_domain/index.html
Create a directory for access logs owned by the caddy user:
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy
Back up the default configuration and open the Caddyfile:
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.orig
sudo nano /etc/caddy/Caddyfile
Replace its contents with the following, changing your_domain and the email address:
{
email your_email@your_domain
}
your_domain {
root * /var/www/your_domain
encode zstd gzip
file_server
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/your_domain.log
}
}
www.your_domain {
redir https://your_domain{uri} permanent
}
What each part does:
- The first block, with no address, holds global options.
emailis the account email for the certificate authority, used for expiry notices. rootsets the document root for all requests (*), andfile_serverserves files from it.encode zstd gzipcompresses responses for clients that support it.headeradds security headers to every response.logwrites access logs in JSON to a file, which Caddy rotates automatically.- The
www.your_domainblock redirects to the bare domain, keeping the path and query string through the{uri}placeholder. Remove it if you do not have awwwDNS record, or Caddy will keep failing to obtain a certificate for it.
Validate the file:
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
...
Valid configuration
Reload Caddy to apply it. A reload applies the new configuration without dropping connections, and if the new configuration fails, Caddy keeps running with the old one:
sudo systemctl reload caddy
Caddy now requests a certificate. Watch it happen in the journal:
sudo journalctl -u caddy -n 30 --no-pager | grep -i certificate
... "msg":"certificate obtained successfully","identifier":"your_domain" ...
Test the site from your own machine:
curl -I https://your_domain
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/html; charset=utf-8
server: Caddy
strict-transport-security: max-age=31536000
...
Plain HTTP now redirects to HTTPS:
curl -I http://your_domain
HTTP/1.1 308 Permanent Redirect
Location: https://your_domain/
Server: Caddy
Step 5 - Understanding certificate storage and renewal
Caddy stores certificates and ACME account keys in the caddy user's data directory, /var/lib/caddy/.local/share/caddy. List the certificates it manages:
sudo ls /var/lib/caddy/.local/share/caddy/certificates/
acme-v02.api.letsencrypt.org-directory
Renewal is automatic: Caddy checks certificates periodically and renews them well before they expire, so there is no cron job or certbot timer to maintain. Keep this directory in your backups; if it is lost, Caddy simply requests new certificates, but repeated losses can hit the certificate authority's rate limits.
Step 6 - Reverse proxying an application
Caddy is also a capable reverse proxy. Suppose you have an application (Node.js, Python, Go, a Docker container) listening on 127.0.0.1:3000. Add a new site block at the end of /etc/caddy/Caddyfile:
sudo nano /etc/caddy/Caddyfile
app.your_domain {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
log {
output file /var/log/caddy/app.your_domain.log
}
}
reverse_proxy forwards requests to the backend and adds the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers, so the application sees the real client IP and knows the original request used HTTPS. It also proxies WebSocket connections without extra configuration.
To send only part of the site to the backend, use a handle_path block, which also strips the prefix before forwarding. For example, to serve static files but send /api/ to the application:
app.your_domain {
handle_path /api/* {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /var/www/app
file_server
}
}
With handle_path, a request for /api/users reaches the backend as /users. Use handle instead if the backend expects the full path.
If you have several instances of the application, list them all and Caddy load-balances between them, taking unhealthy ones out of rotation:
reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 {
lb_policy least_conn
health_uri /health
}
The health_uri path must exist in your application and return a 2xx status. Validate and reload after each change:
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Test the proxy:
curl -I https://app.your_domain
A 502 Bad Gateway response means Caddy is working but the backend is not listening on the configured address.
Step 7 - Serving PHP with PHP-FPM
Caddy runs PHP applications (WordPress, Laravel and others) through PHP-FPM. Ubuntu 24.04 ships PHP 8.3:
sudo apt install -y php8.3-fpm
PHP-FPM listens on the Unix socket /run/php/php8.3-fpm.sock, which belongs to the www-data group. Add the caddy user to that group so it can connect, then restart Caddy so the new group membership takes effect:
sudo usermod -aG www-data caddy
sudo systemctl restart caddy
Create a test site:
sudo mkdir -p /var/www/php.your_domain
echo '<?php echo "PHP " . PHP_VERSION . " via Caddy\n";' | sudo tee /var/www/php.your_domain/index.php
Add a site block to /etc/caddy/Caddyfile:
php.your_domain {
root * /var/www/php.your_domain
encode zstd gzip
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}
The php_fastcgi directive sends .php requests to PHP-FPM and rewrites requests for missing files to index.php, which is what front-controller frameworks such as Laravel and WordPress expect. file_server serves the remaining static assets. Validate, reload and test:
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl https://php.your_domain
PHP 8.3.6 via Caddy
Remove the test file when you are done, and never leave a phpinfo() page on a public server.
Troubleshooting
- Caddy fails to start with
address already in use: another web server holds port 80 or 443. Find it withsudo ss -ltnp 'sport = :80'and stop it, for examplesudo systemctl disable --now nginx. - Certificates are not issued: read the errors with
sudo journalctl -u caddy --no-pager | grep -i error. The usual causes are a DNS record that does not point to the server yet, ports 80 and 443 blocked by a firewall in front of the server, or a site block for a hostname that has no DNS record. 403 Forbiddenor404for files that exist: thecaddyuser cannot read them, orrootpoints to the wrong directory. Check withsudo -u caddy cat /var/www/your_domain/index.html.502 Bad Gatewayon the PHP site withpermission deniedin the journal: thecaddyuser is not in thewww-datagroup, or Caddy was only reloaded instead of restarted afterusermod. Run the commands in Step 7 again.- A reload does nothing: a configuration error makes Caddy keep the previous configuration. Run
sudo -u caddy caddy validate --config /etc/caddy/Caddyfileto see the error.
Conclusion
You installed Caddy from its official repository and configured it to serve a static site with automatic HTTPS and security headers, reverse proxy a backend application with optional load balancing, and run PHP through PHP-FPM. Certificates are obtained and renewed without any extra tooling.
As next steps, split large configurations into files under /etc/caddy/ and load them with the import directive, add basic_auth to protect admin paths, or use the DNS challenge with a Caddy DNS provider module to obtain wildcard certificates.
