nginx
Exponential ships no nginx configuration of its own; nginx.conf in this directory is a translation of
.htaccess_root (this release) into nginx directives, rule for rule. It extends the server block of
chapter 8, section 8.6 with HTTPS, HTTP/2, a port 80
redirect and the security headers. Compare it with .htaccess_root after every upgrade.
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.
nginx never reads .htaccess, so ./console exp:setup:htaccess --apply does not protect an nginx site: the rules
below do. The console is still useful here: ./console exp:setup:config --apply puts config.php into place, and
after an upgrade a .htaccess copied with exp:setup:htaccess makes ./console exp:setup:htaccess show, as a diff,
which rules of .htaccess_root changed. See
Setting up .htaccess and config.php from the console.
Install it
- Copy the file to
/etc/nginx/conf.d/example.com.conf. It is read inside thehttp {}block: it holdsmapblocks as well asserverblocks. (Debian'ssites-availablefiles are read there too.) - Replace
example.com,/var/www/example.com, the PHP-FPM socket (/run/php-fpm/www.sock, five times) and the certificate paths. - Create the PHP-FPM pool (PHP-FPM pool); nginx's user must be able to open its socket.
- Get the certificate, for example
certbot certonly --webroot -w /var/www/example.com -d example.com -d www.example.com; the port 80 server serves/.well-known/acme-challenge/for it. nginx -t, thensystemctl reload nginx.
http2 on; needs nginx 1.25.1 or later; on older versions write listen 443 ssl http2; instead.
The file
# Exponential 6: nginx with PHP-FPM, HTTPS and HTTP/2
#
# 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: a translation of .htaccess_root (this
# release) into nginx directives, rule for rule. Compare it with
# .htaccess_root after every upgrade. Explained in nginx.md next to this file.
#
# Install: copy to /etc/nginx/conf.d/example.com.conf (it is read inside the
# http {} block: it holds "map" and "server" blocks), replace example.com,
# /var/www/example.com, the PHP-FPM socket and the certificate paths, then
# "nginx -t" and "systemctl reload nginx".
# Security headers on what nginx serves itself, with the values of the
# kernel's site.ini [HTTPHeaderSettings] SecurityHeaders[]. The kernel sends
# them on its pages; each map yields an empty value (no header) when PHP
# already sent it, so a page never gets the header twice.
map $upstream_http_x_content_type_options $exp_xcto {
"" "nosniff";
default "";
}
map $upstream_http_x_frame_options $exp_xfo {
"" "SAMEORIGIN";
default "";
}
map $upstream_http_referrer_policy $exp_rp {
"" "strict-origin-when-cross-origin";
default "";
}
map $upstream_http_permissions_policy $exp_pp {
"" "camera=(), microphone=(), payment=(), usb=()";
default "";
}
# Plain HTTP: the ACME challenge files, and a redirect for everything else
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
location ^~ /.well-known/acme-challenge/ {
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
# The installation root, where index.php is
root /var/www/example.com;
index index.php;
# Uploads: nginx's default limit is 1 MB
client_max_body_size 64m;
add_header X-Content-Type-Options $exp_xcto always;
add_header X-Frame-Options $exp_xfo always;
add_header Referrer-Policy $exp_rp always;
add_header Permissions-Policy $exp_pp always;
# HSTS once every name is served over HTTPS for good; start short. Here or
# in site.ini SecurityHeaders[Strict-Transport-Security], not both.
#add_header Strict-Transport-Security "max-age=300" always;
# 1. No dot file or dot directory, anywhere; .well-known stays reachable
location ~ (^|/)\.(?!well-known(/|$)) {
return 404;
}
# 2. No script below the root runs because its path was asked for
# (.htaccess_root answers 404 only when the file exists; this answers 404 always)
location ~ ^/.+/[^/]+\.(php[0-9]?|phtml|phar)$ {
return 404;
}
# 3. Front controllers
location ^~ /api/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_rest.php;
fastcgi_param SCRIPT_NAME /index_rest.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location = /index_rest.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_rest.php;
fastcgi_param SCRIPT_NAME /index_rest.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
# The SOAP server (soap.ini EnableSOAP=true) is index_soap.php since
# Exponential 6.0.15; soap.php only forwards to it. Uncomment with SOAP on:
#location = /index_soap.php {
# include fastcgi_params;
# fastcgi_param SCRIPT_FILENAME $document_root/index_soap.php;
# fastcgi_param SCRIPT_NAME /index_soap.php;
# fastcgi_pass unix:/run/php-fpm/www.sock;
#}
location ~ ^/([^/]+/)?content/treemenu {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index_treemenu.php;
fastcgi_param SCRIPT_NAME /index_treemenu.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
# 4. Files served as they are (the "- [L]" rules of .htaccess_root);
# a listed path that does not exist is a 404, as under Apache.
# Uploaded SVG images first: they run nothing and load nothing when opened
# directly (.htaccess_root's FilesMatch for .svg; here for the storage,
# where the uploads are). A location with add_header of its own does not
# inherit the server's, so the other headers are repeated.
location ~* ^/var/([^/]+/)?storage/(images(-versioned)?/.+\.svgz?|original/image/.+\.svg)$ {
add_header Content-Security-Policy "sandbox; default-src 'none'; style-src 'unsafe-inline'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
try_files $uri =404;
}
location ~ ^/var/([^/]+/)?storage/images(-versioned)?/ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?storage/original/image/.+\.(png|jpe?g|gif|webp|svg)$ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?storage/pdf/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$ { try_files $uri =404; }
location ~ ^/var/([^/]+/)?cache/(texttoimage|public)/ { try_files $uri =404; }
# With site.ini [FileSettings] CacheVarDir=var_cache (multi-site hosting):
#location ~ ^/var_cache/([^/]+/)?cache/(texttoimage|public)/ { try_files $uri =404; }
location ~ ^/design/[^/]+/(stylesheets|images|javascript|fonts)/ { try_files $uri =404; }
location ~ ^/share/icons/ { try_files $uri =404; }
location ~ ^/extension/[^/]+/icons/[A-Za-z0-9_/-]+\.(svg|png|gif)$ { try_files $uri =404; }
location ~ ^/extension/[^/]+/design/[^/]+/(stylesheets|flash|images|lib|javascripts?|fonts|vendor|media)/ { try_files $uri =404; }
location ~ ^/packages/styles/.+/(stylesheets|images|javascript)/[^/]+/ { try_files $uri =404; }
location ~ ^/packages/styles/.+/thumbnail/ { try_files $uri =404; }
location ~ ^/var/storage/packages/.+\.(png|jpe?g|gif|webp)$ { try_files $uri =404; }
location = /favicon.ico { try_files $uri =404; }
location = /robots.txt { try_files $uri =404; }
location = /index.js { try_files $uri =404; }
location = /sw.js { try_files $uri =404; }
location = /w3c/p3p.xml { try_files $uri =404; }
# 5. Everything else goes to index.php, with the original REQUEST_URI:
# settings/, var/, vendor/, kernel/ and composer.json included, which
# index.php answers with a page or a 404, never with the file
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
}How it maps, and why it is ordered this way
- nginx chooses a location by its own precedence, not by file order: exact matches (
=) first, then the longest^~prefix, then regular expressions in the order they appear, then the longest plain prefix. The dot-path and script rules are therefore the first regular expressions, as they are the first rules of.htaccess_root;location /catches the rest, like the finalRewriteRule .* index.php. - The front controllers are named in
SCRIPT_FILENAME; the request is not rewritten, soREQUEST_URI(fromfastcgi_params) still carries the address the visitor asked for, as with Apache. - The SOAP server is
index_soap.phpsince Exponential 6.0.15 (soap.phpbefore, now a shim that forwards to it). Its location is commented out, as SOAP is off by default; uncomment it when you switch SOAP on, and change alocation = /soap.phpof an older configuration to it. The test withcurlis on the SOAP entry script is now index_soap.php. - The static locations have no
fastcgi_pass, so no PHP runs there, and the script rule before them makes sure a.phpbelow an asset directory is not sent as text either. The script rule answers 404 for any such path, existing or not, which is a little stricter than.htaccess_root. settings/,var/log/,vendor/,kernel/,composer.jsonand every other path not on the list reachlocation /and are answered byindex.php, never sent.- Headers.
add_headeris inherited only by locations without anadd_headerof their own, and it cannot test whether PHP already sent a header. Themapblocks solve the second: each yields an empty value, which nginx does not send, when the response from PHP already has the header. The SVG location repeats the headers because it has its ownadd_header. fastcgi_paramsof the nginx packages passesHTTPSon a TLS connection, which is what Exponential checks first.
Behind a proxy
When TLS ends at a load balancer in front of nginx, keep one server on the port the balancer talks to (with the
locations of the 443 server). nginx passes every request header to PHP (X-Forwarded-Proto arrives as
HTTP_X_FORWARDED_PROTO); the kernel believes it only from an address listed in
site.ini [HTTPHeaderSettings] TrustedProxies[]
(chapter 8, section 8.8).
WebDAV
This file does not route WebDAV (off by default). To serve it, add a location ^~ /index_webdav.php before the
catch-all, with fastcgi_param SCRIPT_FILENAME $document_root/index_webdav.php and SCRIPT_NAME /index_webdav.php,
or a server of its own for a WebDAV host. Since 6.0.15 the script is index_webdav.php; a block written for
webdav.php keeps working through a forwarding shim until a later major version, but change both the location
and SCRIPT_FILENAME. Before/after and the test (curl -X PROPFIND -H 'Depth: 1', expect 207 Multi-Status):
The WebDAV entry script is now index_webdav.php.
Check it
nginx -t
for p in /.git/config /.env /kernel/classes/ezini.php /extension/ezjscore/ezinfo.php; do
printf '%-34s %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 file
curl -sI https://example.com/design/admin4/stylesheets/components.css | grep -i -E '^(HTTP|x-content-type)'How this example was checked: nginx -t with nginx 1.28, then a throwaway nginx with this file (ports, paths, socket
and a self-signed certificate substituted) answered every request of the test list as expected
over HTTPS, including the security headers and the SVG sandbox, redirected plain HTTP and served an ACME challenge
file on port 80.
Related pages
- Chapter 8, section 8.6: nginx with PHP-FPM.
- Chapter 13, section 13.2: What the web server must never hand out.
- PHP-FPM pool, Apache .htaccess (the rules explained one by one).
- Setting up .htaccess and config.php from the console: which servers read
.htaccess, andconfig.php. - Web server examples and all examples.
- Free documentation: this page is free to copy, translate and improve.