Caddy
Caddy is a modern, open-source web server written in Go. Its main draw is automatic HTTPS: Caddy obtains and renews TLS certificates for you with no extra configuration. Like Nginx, Caddy does not read Apache .htaccess files, so Grav ships an equivalent Caddyfile in the webserver-configs/ folder of every release.
Requirements
caddy(v2 or newer)php-fpm(FastCGI Process Manager) to process PHP scripts
Quick start
Grav ships a ready-to-run Caddyfile in webserver-configs/. To try it locally, run the following from the root of your Grav site:
caddy run --config webserver-configs/Caddyfile
By default this serves the site at https://localhost and redirects http://localhost to it (Caddy v2 enables HTTPS automatically). Make sure your PHP-FPM process is listening on 127.0.0.1:9000, or adjust the php_fastcgi address to match your setup.
The bundled Caddyfile
The shipped file is the canonical reference. For production, replace localhost with your domain so Caddy can provision a real certificate, and point root at your Grav directory:
1example.com
2encode gzip
3root * /var/www/grav
4
5# Begin - Security
6# deny all direct access for these folders
7@denied_dirs path_regexp (?i)^/(\.git|cache|bin|logs|backups?|tmp|webserver-configs|tests)/
8
9# deny all direct access to these sensitive user folders, whatever the file type
10@denied_user_config path_regexp (?i)^/user/(env/[^/]+/)?config/
11
12# block user/accounts too, but allow avatar images to be served directly, whether
13# stored at user/accounts/avatars/<file> (flatfile accounts) or user/accounts/
14# <username>/<file> (Flex folder storage). SVG is excluded as a stored-XSS vector.
15@denied_user_accounts {
16 path_regexp (?i)^/user/(env/[^/]+/)?accounts/
17 not path_regexp (?i)^/user/(env/[^/]+/)?accounts/[^/]+/[^/]+\.(jpe?g|png|gif|webp|avif|bmp|ico)$
18}
19
20# block user/data too, but allow public media uploads (e.g. Flex Object images)
21# to be served directly. SVG is intentionally excluded as a stored-XSS vector.
22@denied_user_data {
23 path_regexp (?i)^/user/(env/[^/]+/)?data/
24 not path_regexp (?i)\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf)$
25}
26
27# deny running scripts in the public cache folders (image derivatives and combined assets)
28@denied_cache_scripts path_regexp (?i)^/(images|assets)/.*\.(php|php2|php3|php4|php5|php7|php8|phar|phtml|pht|phtm|phps|pl|py|cgi|sh|bat)$
29
30# deny running scripts inside core system folders
31@denied_system path_regexp (?i)^/(system|vendor)/.*\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$
32
33# deny running scripts inside user folder
34@denied_user_scripts path_regexp (?i)^/user/.*\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$
35
36# deny access to specific files in the root folder
37@denied_root_files path_regexp (?i)^/(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt)$
38
39# deny Markdown source files such as README.md and CHANGELOG.md; a page route
40# ending in .md is not a file, so it still reaches Grav's Markdown output
41@denied_markdown_files {
42 path_regexp (?i)\.md$
43 file
44}
45
46# deny hidden files and directories at any depth (.htaccess, .env, nested .git
47# repositories), except /.well-known/ files whose own names don't start with a
48# dot (ACME challenges, security.txt)
49@denied_dotfiles {
50 path_regexp (?i)(^|/)\.
51 not path_regexp ^/\.well-known(/[^./][^/]*)+$
52}
53## End - Security
54
55route {
56 respond @denied_dirs 403
57 respond @denied_user_config 403
58 respond @denied_user_accounts 403
59 respond @denied_user_data 403
60 respond @denied_cache_scripts 403
61 respond @denied_system 403
62 respond @denied_user_scripts 403
63 respond @denied_root_files 403
64 respond @denied_markdown_files 403
65 respond @denied_dotfiles 403
66
67 # global rewrite should come last
68 try_files {path} {path}/ /index.php?_url={uri}&{query}
69
70 php_fastcgi 127.0.0.1:9000
71 file_server
72}
Note
Caddy's path matcher is literal and only understands * wildcards, so every rule that needs alternation, character classes or anchors uses a named path_regexp matcher. Outside a route block Caddy applies its own directive order, and the global try_files rewrite would run before respond, so the rules sit in a route block, which runs top to bottom as written. Because Go's RE2 engine has no lookbehind or lookahead, the user/accounts, user/data and .well-known exceptions are negated matchers rather than single patterns. The Markdown rule pairs path_regexp with the file matcher, so only .md files that exist on disk are refused and page routes ending in .md still reach Grav's Markdown output. The config, accounts and data rules also match under an optional env/<name>/ prefix, so a multisite subsite's own folders are protected while its themes, plugins and assets stay public. Caddy does not read .htaccess, so when you upgrade Grav, compare your Caddyfile with the shipped webserver-configs/Caddyfile and copy any changed rules by hand.
Running as a service
For a permanent install, place your edited file at /etc/caddy/Caddyfile and manage Caddy with systemd:
sudo systemctl reload caddy
Confirming it works
A request for a private file such as https://example.com/user/config/system.yaml should return 403 Forbidden, while a media file you uploaded under user/data (for example a .jpg) should still load normally. See the User Folder Exposure page for more on the admin warning that fires when these rules are not applied.