server {
#listen 80;
index index.html index.php;
## Begin - Server Info
root /home/USER/www/html;
server_name localhost;
## End - Server Info
## Begin - Index
# for subfolders, simply adjust:
# `location /subfolder {`
# and the rewrite to use `/subfolder/index.php`
location / {
try_files $uri $uri/ /index.php?$query_string;
}
## End - Index
## Begin - Security
# deny hidden files and directories at any depth, except ACME challenges
location ~ (^|/)\.(?!well-known(?:/|$)) { return 403; }
# deny all direct access for these folders
location ~* ^/(\.git|cache|bin|logs|backup|tmp|webserver-configs|tests)/.*$ { return 403; }
# deny all direct access to these sensitive user folders, whatever the file type
location ~* ^/user/(env/[^/]+/)?config/.*$ { return 403; }
# allow avatar images under user/accounts to be served directly, whether stored
# at user/accounts/avatars/ (flatfile accounts) or user/accounts//
# (Flex folder storage); this must come before the user/accounts deny so it
# wins the first-match. SVG is intentionally excluded as a stored-XSS vector.
location ~* ^/user/(env/[^/]+/)?accounts/[^/]+/[^/]+\.(jpe?g|png|gif|webp|avif|bmp|ico)$ { try_files $uri =404; }
# deny everything else under user/accounts, whatever the file type
location ~* ^/user/(env/[^/]+/)?accounts/.*$ { return 403; }
# allow public media uploads under user/data (e.g. Flex Object images) to be
# served directly; this must come before the user/data deny so it wins the
# first-match. SVG is intentionally excluded as a stored-XSS vector.
location ~* ^/user/(env/[^/]+/)?data/.*\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf)$ { try_files $uri =404; }
# deny everything else under user/data, whatever the file type
location ~* ^/user/(env/[^/]+/)?data/.*$ { return 403; }
# Serve page media only through the page route, so plugins can apply the
# page's `access` rules to it. DO NOT uncomment without first setting
# `pages.media_route_urls: true` in user/config/system.yaml, or every media
# URL on the site becomes a 403.
# location ~* ^/user/pages/.*$ { return 403; }
# deny running scripts in the public cache folders (image derivatives and combined assets)
location ~* ^/(images|assets)/.*\.(php|php2|php3|php4|php5|php7|php8|phar|phtml|pht|phtm|phps|pl|py|cgi|sh|bat)$ { return 403; }
# deny running scripts inside core system folders
location ~* ^/(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)$ { return 403; }
# deny running scripts inside user folder
location ~* ^/user/.*\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ { return 403; }
# deny access to specific files in the root folder
location ~* ^/(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess) { return 403; }
# deny access to .env environment files (expression purposefully not anchored)
location ~ /\.env(\.|$) { return 403; }
# deny Markdown source files such as README.md and CHANGELOG.md; a page route
# ending in .md is not a file, so it still reaches Grav's Markdown output
location ~* \.md$ {
if (-f $request_filename) { return 403; }
try_files $uri /index.php?$query_string;
}
## End - Security
## Begin - Static asset caching
# MUST stay below the security block. nginx picks the FIRST matching regex
# location, so a caching rule placed above would answer for paths the denies
# above are meant to refuse. For the same reason these use regex (`~*`) and
# never `^~`: a `^~` prefix match wins over every regex location regardless
# of order, which would bypass the user/accounts and user/data rules.
# Content-hashed bundles shipped by SvelteKit-based plugins and themes (the
# Admin panel among them). The filename changes whenever the contents do, so
# these can be cached permanently. `=404` keeps a missing chunk from booting
# PHP just to render a 404 — and, on any host that serves an SPA from a
# static index.html fallback, stops a deleted chunk being answered with the
# HTML shell at 200, which a browser cannot parse as a module.
location ~* /_app/immutable/.*\.(js|css|woff2?)$ {
try_files $uri =404;
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Everything else static gets a moderate TTL and still revalidates via the
# ETag/Last-Modified nginx already sends. Deliberately NOT `immutable`:
# these filenames are stable across edits, so a long hard cache would pin
# stale media until the name changed.
# The try_files fallback is load-bearing, not boilerplate: Grav serves some
# media through index.php (cached image derivatives, plugin media proxies),
# so a bare static handler would hard-404 assets that PHP would have
# produced on demand. Adding caching must not change WHAT is served.
location ~* \.(jpe?g|png|gif|webp|avif|bmp|ico|svg|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|css|js|woff2?|ttf|otf|eot)$ {
try_files $uri $uri/ /index.php?$query_string;
expires 30d;
access_log off;
}
## End - Static asset caching
## Begin - PHP
location ~ \.php$ {
# Choose either a socket or TCP/IP address
fastcgi_pass unix:/var/run/php/php-fpm.sock;
# fastcgi_pass unix:/var/run/php5-fpm.sock; #legacy
# fastcgi_pass 127.0.0.1:9000;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
}
## End - PHP
}