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 sudo privileges.
  • A domain name with a DNS A record (and AAAA if you use IPv6) for your_domain pointing to your server's public IP. Step 6 also uses app.your_domain, and Step 7 uses php.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_domain enables automatic HTTPS: Caddy listens on 443, gets a certificate, and redirects port 80 to HTTPS.
  • A port such as :80 or http://your_domain serves plain HTTP with no certificate.
  • localhost or 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/Caddyfile normalizes indentation.
  • sudo -u caddy caddy validate --config /etc/caddy/Caddyfile parses the file and reports errors without touching the running server. Running it as the caddy user 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. email is the account email for the certificate authority, used for expiry notices.
  • root sets the document root for all requests (*), and file_server serves files from it.
  • encode zstd gzip compresses responses for clients that support it.
  • header adds security headers to every response.
  • log writes access logs in JSON to a file, which Caddy rotates automatically.
  • The www.your_domain block redirects to the bare domain, keeping the path and query string through the {uri} placeholder. Remove it if you do not have a www DNS 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 with sudo ss -ltnp 'sport = :80' and stop it, for example sudo 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 Forbidden or 404 for files that exist: the caddy user cannot read them, or root points to the wrong directory. Check with sudo -u caddy cat /var/www/your_domain/index.html.
  • 502 Bad Gateway on the PHP site with permission denied in the journal: the caddy user is not in the www-data group, or Caddy was only reloaded instead of restarted after usermod. 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/Caddyfile to 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.