Nginx config file examples, tested on real servers
Updated 5 November 2026 · By Brent Alexis
Short answer: below are four complete Nginx configs: a reverse proxy for Node.js, Python or another app, a static site, a PHP-FPM app and WordPress. Each one redirects HTTP to HTTPS, keeps Let's Encrypt renewals working and turns on HTTP/2. Copy the one you need, change example.com and the paths, then run sudo nginx -t before you reload.
Each config was run exactly as ProxyForge generates it on three platforms: the official nginx Docker image (Nginx 1.30.6 with PHP 8.3), Debian 12's own packages (Nginx 1.22.1, PHP 8.2) and AlmaLinux 9's own packages (Nginx 1.20.1, PHP 8.0), with real requests: 153 checks. All three PHP builds include the PHP2Shell fix. How it's tested
- Where the file goes
- Reverse proxy (Node.js, Python, Docker)
- Static site
- PHP-FPM app (and keeping PHP patched)
- WordPress
- Older Nginx and RHEL differences
- Test and reload
Where the file goes
| System | Save as |
|---|---|
| Debian, Ubuntu | /etc/nginx/sites-available/example.com, then enable it with sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ |
| RHEL, AlmaLinux, Rocky, Amazon Linux, nginx.org packages, the Docker image | /etc/nginx/conf.d/example.com.conf. The name must end in .conf: the main nginx.conf only loads files that match conf.d/*.conf. |
Each example needs a certificate at /etc/letsencrypt/live/example.com/ before nginx -t will pass. If you don't have one yet, see Test and reload.
Reverse proxy (Node.js, Python, Docker)
For an app that runs its own web server on a local port: Node.js, Python (Gunicorn, Uvicorn), Go, or a Docker container with a published port. Nginx handles HTTPS and passes requests to the app.
# WebSocket helper: sends "Connection: upgrade" only when the client asks to upgrade,
# and "close" otherwise. Must sit outside any server block. If another site file
# already defines this map, you can leave this copy out.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com www.example.com;
# Let’s Encrypt renewals (certbot --webroot): answer the check over plain HTTP from the
# folder you passed to certbot -w. Change the path below if you used a different one.
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Everything else goes to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# Assumes a certificate already exists at this path (e.g. issued via Certbot)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
location / {
client_max_body_size 10m;
# RHEL/Alma/Rocky: a 502 with "(13: Permission denied)" in the error log is SELinux.
# Allow Nginx to reach the backend: sudo setsebool -P httpd_can_network_connect 1
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
Checked on all three platforms: requests reach the app with their path and query; the app receives Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto: https; a 9 MB upload goes through and an 11 MB one gets 413; WebSocket upgrades work; HTTP redirects to HTTPS with the path and query kept; the renewal check file is served; HTTP/2 is on.
- Change
127.0.0.1:3000to the address your app listens on, and10mto the largest upload you need. - The
mapblock is for WebSockets and must sit outside theserverblocks. If another site file already has the samemap, you can leave this copy out. - The four
proxy_set_headerlines tell your app the real domain, the visitor's IP and that the request came in over HTTPS. WithoutX-Forwarded-Proto, many apps buildhttp://links or loop on their own HTTPS redirect. - The renewal check is served from
/var/www/html, the folder you pass tocertbot -w, because a proxied app usually has no folder of its own.
Static site
For plain HTML, CSS and JavaScript, including the build output of a static site generator or a single-page app.
server {
listen 80;
server_name example.com www.example.com;
# Let’s Encrypt renewals (certbot --webroot): answer the check over plain HTTP from the
# folder you passed to certbot -w. Change the path below if you used a different one.
location /.well-known/acme-challenge/ {
root /var/www/example.com;
}
# Everything else goes to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# Assumes a certificate already exists at this path (e.g. issued via Certbot)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Checked on all three platforms: the home page and folder index pages load, a missing page is a 404, CSS is sent gzipped, plus the HTTPS redirect, the renewal check and HTTP/2.
- Change
/var/www/example.comto the folder with yourindex.html. - Single-page apps (React, Vue) that handle their own routes need
try_files $uri $uri/ /index.html;instead, so a refresh on/dashboardloads the app instead of a 404.
PHP-FPM app
For PHP files that each run on their own, like a small site or a tool. Nginx serves the static files and hands .php requests to PHP-FPM.
server {
listen 80;
server_name example.com www.example.com;
# Let’s Encrypt renewals (certbot --webroot): answer the check over plain HTTP from the
# folder you passed to certbot -w. Change the path below if you used a different one.
location /.well-known/acme-challenge/ {
root /var/www/example.com;
}
# Everything else goes to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# Assumes a certificate already exists at this path (e.g. issued via Certbot)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
root /var/www/example.com;
index index.php;
# Largest upload Nginx accepts (its default is 1m). PHP has its own limits too:
# upload_max_filesize and post_max_size in php.ini or the PHP-FPM pool
client_max_body_size 10m;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
# 404 for .php files that don’t exist, instead of passing them to PHP
try_files $uri =404;
# fastcgi_params ships with every Nginx package (Debian, RHEL, nginx.org)
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Socket: Debian/Ubuntu unix:/run/php/php8.X-fpm.sock, RHEL/Alma/Rocky unix:/run/php-fpm/www.sock
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
Checked on all three platforms: PHP runs, / runs index.php, a missing .php file is a 404 from Nginx, code hidden inside an uploaded image doesn't run, static files are still served, a 1.5 MB upload goes through and an 11 MB one gets 413, and /about/ is a 404 because this config has no front-controller routing.
- Change the
fastcgi_passsocket to match your PHP:System fastcgi_pass Debian, Ubuntu unix:/run/php/php8.X-fpm.sockwith your PHP version (ls /run/php/shows it)RHEL, AlmaLinux, Rocky unix:/run/php-fpm/www.sockPHP in a Docker container its name and port, for example php:9000 - Change
client_max_body_size 10mto the largest upload you need. Nginx's own default is only 1 MB, and PHP has its own upload limits on top: see Fix 413 Request Entity Too Large. try_files $uri =404;inside the PHP block matters. Without it, a request like/uploads/photo.jpg/x.phpcan make PHP run code hidden inside an uploaded file. With it, Nginx answers 404 becausex.phpdoesn't exist.- This config doesn't route clean URLs like
/about/toindex.php. If your app uses a front controller (Laravel, Symfony, WordPress), use the WordPress example'slocation /line instead.
Keep PHP itself patched
No Nginx config protects against a bug inside PHP. In October 2026, two flaws nicknamed PHP2Shell (CVE-2026-109618 and CVE-2026-109619) let a crafted upload run code on the server, with no login needed, through PHP-FPM, mod_php and other setups. They're fixed in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 21 October 2026. PHP 8.1 and older get no official fix from PHP.
- Check your version with
php -v, orphp-fpm8.X -von Debian and Ubuntu. - Distro packages can carry the fix without the version changing. RHEL, AlmaLinux and Rocky 9 keep PHP 8.0.30 and backport it, so check the package instead:
rpm -q --changelog php-fpm | grep CVE-2026-109618. Any output means the fix is installed. On Debian 12 the fix came as the new version 8.2.35, sophp-fpm8.2 -vis enough there. Ubuntu wasn't part of this lab, so check Ubuntu's security notices for your release. - Restart PHP-FPM after updating (for example
sudo systemctl restart php8.3-fpm, orsudo systemctl restart php-fpmon RHEL) so the running workers load the fixed version. - A third flaw, CVE-2026-115560, had no fixed release when this guide was updated. It only affects apps that switch PHP's session serializer while running. Until PHP's next release, PHP's advice is to set
session.upload_progress.enabled = 0inphp.inior the PHP-FPM pool config, then restart PHP-FPM. The setting is rarely used, and WordPress itself doesn't rely on it.
Checked in the lab: the official image ran PHP 8.3.36 and Debian 12 ran 8.2.35, both fixed versions, and AlmaLinux 9's PHP 8.0.30 package (8.0.30-8.el9_8.1) lists CVE-2026-109618 in its changelog. Upload progress was on by default on all three.
WordPress
The same as the PHP-FPM config, plus the one line WordPress permalinks need.
server {
listen 80;
server_name example.com www.example.com;
# Let’s Encrypt renewals (certbot --webroot): answer the check over plain HTTP from the
# folder you passed to certbot -w. Change the path below if you used a different one.
location /.well-known/acme-challenge/ {
root /var/www/example.com;
}
# Everything else goes to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
# Assumes a certificate already exists at this path (e.g. issued via Certbot)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
root /var/www/example.com;
index index.php;
# Largest upload Nginx accepts (its default is 1m). PHP has its own limits too:
# upload_max_filesize and post_max_size in php.ini or the PHP-FPM pool
client_max_body_size 10m;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
# 404 for .php files that don’t exist, instead of passing them to PHP
try_files $uri =404;
# fastcgi_params ships with every Nginx package (Debian, RHEL, nginx.org)
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Socket: Debian/Ubuntu unix:/run/php/php8.X-fpm.sock, RHEL/Alma/Rocky unix:/run/php-fpm/www.sock
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
Checked on all three platforms with a stand-in index.php that reports what it receives (not a full WordPress install): /about/ and /2026/11/hello-world/?replytocom=5 reach index.php with the original URL and query, /?p=1 works, /wp-admin/ runs wp-admin/index.php, theme files are served directly, a missing .php file is a 404, a 1.5 MB upload goes through and an 11 MB one gets 413.
try_files $uri $uri/ /index.php?$args;serves real files and folders as they are and sends everything else to WordPress, query string included, which is what pretty permalinks need. Nginx ignores.htaccess, so the rules a WordPress plugin writes there do nothing here.- Change the socket the same way as for PHP-FPM, and keep PHP patched: see Keep PHP itself patched. Updating WordPress or its plugins doesn't fix a bug in PHP itself.
Older Nginx and RHEL differences
- Nginx older than 1.25.1 (Ubuntu 22.04 and 24.04, Debian 12 and AlmaLinux 9 packages) doesn't know
http2 on;. Replace those two lines withlisten 443 ssl http2;. That's the only change in ProxyForge's Older Nginx output, and all four configs passed the same checks that way on Debian 12 (Nginx 1.22.1) and AlmaLinux 9 (1.20.1). See Fix "unknown directive http2" for which version each distro installs. - RHEL, AlmaLinux and Rocky: save the file in
conf.dand useunix:/run/php-fpm/www.sock. With SELinux enforcing, a reverse proxy can give 502 Bad Gateway with(13: Permission denied)in the error log until you runsudo setsebool -P httpd_can_network_connect 1. Containers don't run SELinux, so that part wasn't part of this lab. See 502 Bad Gateway.
Test and reload
sudo cp /etc/nginx/sites-available/example.com /etc/nginx/sites-available/example.com.bak # if the file already exists
sudo nginx -t && sudo systemctl reload nginx
curl -I http://example.com/
The last line should show 301 and Location: https://example.com/. If something breaks, put the .bak copy back and run the second line again. On Debian and Ubuntu keep backups in sites-available, never in sites-enabled: Nginx loads every file in sites-enabled, whatever its name.
No certificate yet? nginx -t fails with cannot load certificate until the files exist. Generate the config with HTTPS unticked first and reload. For a static, PHP or WordPress site, then run sudo certbot certonly --webroot -w /var/www/example.com -d example.com -d www.example.com. The plain-HTTP reverse proxy sends every request to your app, including Let's Encrypt's check, so there use Certbot's Nginx plugin instead: sudo certbot certonly --nginx -d example.com -d www.example.com. Once the certificate exists, switch to the HTTPS config above.
If a reload seems to change nothing, see reload said OK, but my change didn't apply.
Generate your own Nginx config →
How to use ProxyForge · FAQ: common errors · Guides · What's new · About