Caddy
Caddy obtains and renews certificates by itself, redirects plain HTTP to HTTPS and speaks
HTTP/2 and HTTP/3. Caddyfile.example serves Exponential with Caddy and a PHP-FPM pool. It is not
shipped by the kernel; its routing is the one of the Caddyfile that exp:velocity generates for its FrankenPHP engine
(kernel/classes/expvelocityfrankenphp.php, caddyfileText()), with PHP handed to PHP-FPM instead of FrankenPHP's
built-in PHP, plus the first two rules of .htaccess_root.
Warning
.htaccess slows down every request. When .htaccess files are enabled (AllowOverride anything but None), let alone
used, Apache looks for them and reads and evaluates them on every request, in every directory of the path, and
page loading times go up drastically (measured). The only real fix is to switch the feature off for
the virtual host: AllowOverride None in the HTTPS virtual host, and the rules in the virtual host's own
configuration as mod_rewrite rules. That is far faster in production speed tests and well worth the time it takes
to optimize a production configuration. Switch over right away: copy the rules of the
example HTTPS virtual host into your virtual host (with the static cache, also
staticcache_offload.conf), set AllowOverride None, run apachectl configtest, reload Apache and
test the site. The same rules for other servers: nginx, LiteSpeed,
Caddy, IIS, Velocity (built in); where virtual host rules go in a control
panel: Plesk, cPanel, DirectAdmin, ISPConfig.
On LiteSpeed or OpenLiteSpeed, the server's own page cache can also answer anonymous visitors and crawlers without PHP: Page caching with LiteSpeed and OpenLiteSpeed.
Caddy never reads .htaccess: ./console exp:setup:htaccess --apply does not protect a Caddy site, the Caddyfile
does. ./console exp:setup:config --apply still puts config.php into place; see
Setting up .htaccess and config.php from the console.
Warning
Do not use Caddy's php_fastcgi (or FrankenPHP's php_server) for Exponential. Both serve every file
that exists before they fall back to index.php: settings/site.ini, settings/override/*.ini.append.php, the
SQLite database and the logs would be downloadable.
Install it
Install Caddy (the distribution's package or caddyserver.com) and create the PHP-FPM pool (PHP-FPM pool); Caddy's user (
caddy) must be able to open the pool's socket (listen.owner,listen.group).Copy the file to
/etc/caddy/Caddyfile(orimportit from there), replaceexample.com, the e-mail address,/var/www/example.comand the socket.Ports 80 and 443 must reach the machine for the certificate.
Check and reload:
caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy
The file
# Exponential 6: Caddy with PHP-FPM (automatic HTTPS, HTTP/2 and HTTP/3)
#
# Free documentation: GFDL 1.3 or later, see doc/eng-us/README-LICENSE.md; rules derived from .htaccess_root (GPL v2 or later).
#
# Derived, not shipped by the kernel: the routing of the Caddyfile that
# "exp:velocity" generates for its FrankenPHP engine
# (kernel/classes/expvelocityfrankenphp.php, caddyfileText()), with PHP
# handed to a PHP-FPM pool instead of FrankenPHP's built-in PHP, plus the
# first two rules of .htaccess_root (dot paths, scripts below the root).
# Explained in caddy.md next to this file.
#
# Never use php_fastcgi or php_server for Exponential: they serve every file
# that exists, settings/*.ini and the SQLite database included.
#
# Install: copy to /etc/caddy/Caddyfile (or import it from there), replace
# example.com, the e-mail address, /var/www/example.com and the PHP-FPM
# socket, then "caddy validate --config /etc/caddy/Caddyfile" and
# "systemctl reload caddy". Caddy obtains and renews the certificate itself
# and redirects plain HTTP to HTTPS.
{
# The address Let's Encrypt writes to about the certificate
email [email protected]
}
example.com, www.example.com {
# The installation root, where index.php is
root * /var/www/example.com
log {
output file /var/log/caddy/example.com-access.log
format json
}
encode zstd gzip
# The values of the kernel's site.ini [HTTPHeaderSettings] SecurityHeaders[];
# "?" sets a header only when the response does not have it yet, so a page
# from index.php keeps the kernel's own.
header {
?X-Content-Type-Options "nosniff"
?X-Frame-Options "SAMEORIGIN"
?Referrer-Policy "strict-origin-when-cross-origin"
?Permissions-Policy "camera=(), microphone=(), payment=(), usb=()"
# HSTS once every name is served over HTTPS for good; start short. Here or
# in site.ini SecurityHeaders[Strict-Transport-Security], not both.
#?Strict-Transport-Security "max-age=300"
}
route {
# 1. No dot file or dot directory, anywhere; .well-known stays reachable
# (Caddy's regular expressions have no look-ahead, hence two matchers)
@dot {
path_regexp dot /\.
not path /.well-known/*
}
respond @dot 404
# 2. No script below the root runs because its path was asked for
@script path_regexp script ^/.+/[^/]+\.(php[0-9]?|phtml|phar)$
respond @script 404
# 3. Uploaded SVG images run nothing and load nothing when opened directly
@svg path_regexp svg (?i)\.svgz?$
header @svg Content-Security-Policy "sandbox; default-src 'none'; style-src 'unsafe-inline'"
# 4. Files served as they are: expVelocity::STATIC_PATHS, the list of
# .htaccess_root, never a script or a dot path below them
@static {
path_regexp static ^/(design/[^/]+/(stylesheets|images|javascript|fonts)/|share/icons/|extension/[^/]+/icons/[A-Za-z0-9_/-]+\.(svg|png|gif)$|extension/[^/]+/design/[^/]+/(stylesheets|flash|images|lib|javascripts?|fonts|vendor|media)/|var/([^/]+/)?storage/images(-versioned)?/|var/([^/]+/)?storage/pdf/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$|var/([^/]+/)?storage/original/image/.+\.(png|jpe?g|gif|webp|svg)$|var/([^/]+/)?cache/(texttoimage|public)/|packages/styles/.+/(stylesheets|images|javascript)/[^/]+/|packages/styles/.+/thumbnail/|var/storage/packages/.+\.(png|jpe?g|gif|webp)$|favicon\.ico$|design/standard/images/favicon\.ico$|robots\.txt$|(index|sw)\.js$|w3c/p3p\.xml$)
not path_regexp (?i)(\.(php\d?|phtml|phar)$|/\.)
}
file_server @static
# 5. Front controllers: the REST API, the tree menu, and index.php for
# everything else (settings/, var/, vendor/, kernel/ and composer.json
# included, which index.php answers with a page, never the file).
# The rewrite keeps REQUEST_URI: Caddy passes the original request URI.
@rest path_regexp rest ^/(api/|index_rest\.php)
rewrite @rest /index_rest.php
@treemenu path_regexp treemenu ^/([^/]+/)?content/treemenu
rewrite @treemenu /index_treemenu.php
@front not path /index_rest.php /index_treemenu.php
rewrite @front /index.php
reverse_proxy unix//run/php-fpm/www.sock {
transport fastcgi {
split .php
}
}
}
}How it works
routekeeps the directives in the written order (Caddy otherwise sorts them by its own rules), which is what makes the list read like.htaccess_root.- Dot paths and scripts below the root answer 404 first. Caddy's regular expressions (Go's RE2) have no
look-ahead, so the
.well-knownexception is a second matcher (not path /.well-known/*). @staticis exactlyexpVelocity::STATIC_PATHS, minus anything matched byexpVelocity::NEVER_STATIC(a script or a dot path below a listed directory).file_server @staticanswers 404 for a listed path that does not exist, as Apache does.- The rewrites send the REST API to
index_rest.php, the tree menu toindex_treemenu.phpand everything else toindex.php, includingsettings/,var/log/,vendor/andcomposer.json, whichindex.phpanswers with a page, never the file. Caddy's FastCGI transport passes the original request URI asREQUEST_URI, so the kernel sees the address the visitor asked for. - Headers:
?Namesets a header only when the response does not have it yet, so the kernel's own headers on its pages win. Uploaded SVGs get thesandboxpolicy.
Check it
caddy validate --config /etc/caddy/Caddyfile
for p in /.git/config /.env /kernel/classes/ezini.php; do
printf '%-28s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.com$p)" # 404 each
done
curl -s https://example.com/settings/site.ini | grep -c '^\[DatabaseSettings\]' # 0: a page, not the fileHow this example was checked: frankenphp validate --adapter caddyfile with FrankenPHP 1.12.7 (Caddy 2.11), which
contains Caddy's standard modules including the FastCGI transport (http.reverse_proxy.transport.fastcgi). It was not
run against a PHP-FPM pool here; the routing it shares with the generated FrankenPHP configuration is what Velocity
serves with its frankenphp engine.
Related pages
- FrankenPHP: the same routing with PHP built into the server.
- Chapter 8, section 8.4: FrankenPHP and FrankenPHP as the Exponential web engine.
- Chapter 13, section 13.2: What the web server must never hand out.
- PHP-FPM pool, Web server examples and all examples.
- Setting up .htaccess and config.php from the console: which servers read
.htaccess, andconfig.php. - Free documentation: this page is free to copy, translate and improve.