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

SystemSave 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.

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.

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.

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.

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.

Older Nginx and RHEL differences

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