---
title: "How to Set Up Nginx for WooCommerce: A Complete Config Example"
description: "How to set up nginx for WordPress and WooCommerce, step by step: a complete config example with page cache, PHP-FPM, Cloudflare IPs and security rules."
url: "https://carthacker.com/hosting/nginx-wordpress-config/"
author: "Adrian Wadowski"
published: "2026-10-31"
updated: "2026-10-31"
section: "Hosting & Security"
image: "https://carthacker.com/wp-content/uploads/nginx-config-for-woocommerce-example.jpg"
publisher: "CartHacker (carthacker.com)"
language: "en"
---

# How to Set Up Nginx for WooCommerce: A Complete Config Example

By [Adrian Wadowski](https://carthacker.com/author/adrian/), building online stores since 1998. Published October 31, 2026. Updated October 31, 2026. 35 min read.

I tested a WooCommerce store on nginx with a page cache. A product page from the cache took **0.3 milliseconds** of server time, and the same page built by PHP took 198 milliseconds. Then I removed the rules that keep the cart out of the cache and added one line that some nginx examples use, and nginx gave one shopper's cart to the next shopper who opened the same add-to-cart link.

Here's how to set up **nginx for WordPress and WooCommerce** with the config file I'd use on a store today, step by step: where to save the file, which values to replace, how to switch it on and how to check that it works. The file has a page cache that never stores a cart, checkout or account page, PHP-FPM, security rules, a login limit, the real visitor IP addresses behind Cloudflare and HTTP/2.

You'll need it if your store runs on your own server or VPS with nginx, for example one you set up with [my Ubuntu 26.04 guide](https://carthacker.com/setup/install-wordpress-ubuntu-26-04/) or [my Debian 13 guide](https://carthacker.com/hosting/install-wordpress-debian-13/). On managed WordPress hosting the host writes this file for you, so you don't need it.

I tested every part of the file on October 28, 2026, on a WooCommerce test store in Docker on my own computer: five products, a self-signed certificate for example.com, nginx 1.30.6 and PHP 8.3.36, with WordPress 7.1.3 running WooCommerce 11.2.1. After the setup steps, you'll find each part of the file in its own section, with the curl command I used and the response I got.

## Before you start

You'll need four things on the server:

- nginx 1.25.1 or newer (step 1 shows how to check it and what to do on Ubuntu 24.04, which has 1.24.0).
- PHP-FPM with WordPress and WooCommerce already installed in a folder such as /var/www/example.com.
- An SSL certificate for your domain. The file tells nginx where the certificate is, and nginx won't start without it. [My Certbot guide](https://carthacker.com/hosting/certbot-nginx/) shows how to get a free one from Let's Encrypt.
- Root access (as a normal user, start with `sudo -i`).

## Step 1: Check your nginx version and PHP-FPM socket

Run these two commands. The first shows the nginx version, and the second shows the name of the PHP-FPM socket, the file nginx uses to send requests to PHP:

```bash
root@server:~# nginx -v
nginx version: nginx/1.30.6
root@server:~# ls /run/php/
php8.3-fpm.pid  php8.3-fpm.sock
```

Write down the .sock name. You'll need it in step 3. The file needs **nginx 1.25.1 or newer**, because that's the version that added the separate `http2` line and made the old form, `listen 443 ssl http2`, deprecated ([nginx changelog](https://nginx.org/en/CHANGES)). I installed each system's own nginx and PHP packages in a new container and tested the file with nginx -t:

| System | nginx from its own packages | This file | PHP-FPM socket |
| --- | --- | --- | --- |
| Ubuntu 24.04.5 LTS | 1.24.0 | fails: unknown directive "http2" | php8.3-fpm.sock |
| Debian 13 | 1.26.3 | passes, after the mkdir in step 4 | php8.4-fpm.sock |
| Ubuntu 26.04.1 LTS | 1.28.3 | passes, after the mkdir in step 4 | php8.5-fpm.sock |
| nginx.org stable, my test store | 1.30.6 | passes | php8.3-fpm.sock (PHP 8.3.36) |

On Ubuntu 24.04 the test fails, and here's the error:

```bash
root@server:~# nginx -v
nginx version: nginx/1.24.0 (Ubuntu)
root@server:~# nginx -t
2026/10/28 00:25:02 [emerg] 309#309: unknown directive "http2" in /etc/nginx/sites-enabled/example.com:22
nginx: configuration file /etc/nginx/nginx.conf test failed
```

You have two options on Ubuntu 24.04. You can install a newer nginx from the nginx.org repository (the steps are on [nginx.org's Linux packages page](https://nginx.org/en/linux_packages.html)), or you can keep nginx 1.24.0 and turn on HTTP/2 the old way, in the listen lines. Run this command after step 3, with your own file name in place of example.com (step 4 then tests the file). With it, nginx -t passes on 1.24.0:

```bash
root@server:~# sed -i -e 's/listen 443 ssl;/listen 443 ssl http2;/' -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' -e '/^    http2 on;/d' /etc/nginx/sites-available/example.com
root@server:~# nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```

Newer nginx versions still accept the old form, but they print a warning on every test: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. After you upgrade nginx, change those lines back to `http2 on;`.

nginx from nginx.org isn't set up like the Ubuntu and Debian packages. Its packages have no sites-enabled folder and read /etc/nginx/conf.d/ instead, and they run nginx as the user nginx, which can't open the PHP-FPM socket of Ubuntu and Debian, so every PHP page would return a 502 error. Before step 2, open /etc/nginx/nginx.conf with nano and change the first line, `user nginx;`, to `user www-data;`.

## Step 2: Save the config file

On Ubuntu and Debian, nginx keeps one file per website in /etc/nginx/sites-available/. Name the file after your domain. For a store at mystore.com, open a new file with `nano /etc/nginx/sites-available/mystore.com`, paste the whole file and save it. With nginx from nginx.org, save it as /etc/nginx/conf.d/mystore.com.conf instead.

If the file already exists (for example from my Ubuntu or Debian guide), copy it first with `cp /etc/nginx/sites-available/mystore.com /root/mystore.com.bak`, then delete everything in it before you paste. If you followed my Ubuntu guide, also delete /etc/nginx/conf.d/fastcgi-cache.conf, because this file has its own cache settings.

Here's the file I tested. I've kept the comments short, because each part has its own section after the setup steps:

```nginx
# /etc/nginx/sites-available/example.com
# nginx configuration for one WordPress and WooCommerce store.
# Tested on 2026-10-28 with nginx 1.30.6 and PHP-FPM 8.3.36 (WordPress 7.1.3, WooCommerce 11.2.1).
# Needs nginx 1.25.1 or newer for "http2 on;". Ubuntu 24.04 ships nginx 1.24.0.
# Replace example.com, the root folder and the PHP socket with your own.

# Page cache storage and the login rate limit (http level, shared by the server blocks)
fastcgi_cache_path /var/cache/nginx/example.com levels=1:2 keys_zone=example_com:32m max_size=1g inactive=7d use_temp_path=off;
limit_req_zone $binary_remote_addr zone=example_com_login:10m rate=6r/m;

# Plain HTTP: send every visitor to the HTTPS address
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /var/www/example.com;
    index index.php;

    # Largest upload: product CSV imports, theme and plugin zips
    client_max_body_size 64m;

    # Real visitor IPs behind Cloudflare (https://www.cloudflare.com/ips/, list of 2026-10-28)
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    set_real_ip_from 103.22.200.0/22;
    set_real_ip_from 103.31.4.0/22;
    set_real_ip_from 141.101.64.0/18;
    set_real_ip_from 108.162.192.0/18;
    set_real_ip_from 190.93.240.0/20;
    set_real_ip_from 188.114.96.0/20;
    set_real_ip_from 197.234.240.0/22;
    set_real_ip_from 198.41.128.0/17;
    set_real_ip_from 162.158.0.0/15;
    set_real_ip_from 104.16.0.0/13;
    set_real_ip_from 104.24.0.0/14;
    set_real_ip_from 172.64.0.0/13;
    set_real_ip_from 131.0.72.0/22;
    set_real_ip_from 2400:cb00::/32;
    set_real_ip_from 2606:4700::/32;
    set_real_ip_from 2803:f800::/32;
    set_real_ip_from 2405:b500::/32;
    set_real_ip_from 2405:8100::/32;
    set_real_ip_from 2a06:98c0::/29;
    set_real_ip_from 2c0f:f248::/32;
    real_ip_header CF-Connecting-IP;

    # Response headers. Keep every add_header on this level: one add_header
    # inside a location drops all of these for that location.
    add_header X-Content-Type-Options "nosniff" always;
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Cache-Status $upstream_cache_status always;

    # Compression (HTML is always included)
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_proxied any;
    gzip_vary on;
    gzip_types text/css text/plain text/xml application/javascript application/json application/xml application/rss+xml image/svg+xml font/ttf font/otf;

    # Page cache: requests that must always reach PHP
    set $skip_cache 0;
    if ($request_method = POST) { set $skip_cache 1; }
    if ($query_string != "") { set $skip_cache 1; }
    if ($request_uri ~* "^/(cart|checkout|my-account)(/|$)|/wc-api/|wc-ajax=|^/wp-admin/|^/wp-json/|/wp-[a-z-]+\.php") { set $skip_cache 1; }
    if ($http_cookie ~* "wordpress_logged_in_|wp_woocommerce_session_|woocommerce_items_in_cart|woocommerce_cart_hash|wp-postpass_|comment_author_") { set $skip_cache 1; }

    # Pretty permalinks: a file, a folder, or WordPress
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # Security rules. nginx picks the first regex location that matches,
    # so these stay above the PHP location.
    location = /xmlrpc.php {
        deny all;
    }
    location = /wp-login.php {
        limit_req zone=example_com_login burst=5 nodelay;
        limit_req_status 429;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
    location ^~ /wp-content/uploads/woocommerce_uploads/ {
        internal;
    }
    location ^~ /wp-content/upgrade/ {
        deny all;
    }
    location ~ /\.(?!well-known/) {
        deny all;
    }
    location ~* ^/wp-config {
        deny all;
    }
    location ~* \.(?:bak|old|orig|save|swp|sql|log)$ {
        deny all;
    }
    location ~* ^/(?:readme\.html|license\.txt)$ {
        deny all;
    }
    location ~* ^/wp-content/uploads/.*\.php$ {
        deny all;
    }

    # Static files: long browser cache, no access log
    location ~* \.(?:css|js|mjs|woff2?|ttf|otf)$ {
        expires 1y;
        access_log off;
    }
    location ~* \.(?:jpe?g|png|gif|webp|avif|svg|ico)$ {
        expires 30d;
        access_log off;
    }

    # PHP through PHP-FPM, with the page cache in front of it
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;

        fastcgi_cache example_com;
        fastcgi_cache_key "$scheme$request_method$host$request_uri";
        fastcgi_cache_valid 200 301 60m;
        fastcgi_cache_use_stale error timeout updating http_500 http_503;
        fastcgi_cache_background_update on;
        fastcgi_cache_lock on;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;
    }
}
```

## Step 3: Replace these values in the file

The file uses example.com and the paths of my test store, so you'll need to change these values before you test it:

| In the file | Where | Replace with |
| --- | --- | --- |
| example.com and www.example.com | both server_name lines | your domain and its www name |
| https://example.com | the return 301 line in the port 80 block | your store's address, as in WordPress **Settings > General > Site Address (URL)** |
| /var/cache/nginx/example.com | the fastcgi_cache_path line | /var/cache/nginx/ followed by your domain, for example /var/cache/nginx/mystore.com |
| /etc/letsencrypt/live/example.com/ | the ssl_certificate and ssl_certificate_key lines | the paths of your certificate (`certbot certificates` lists them) |
| /var/www/example.com | the root line | the folder where WordPress is installed |
| unix:/run/php/php8.3-fpm.sock | both fastcgi_pass lines | the socket from step 1, for example php8.5-fpm.sock on Ubuntu 26.04 |
| the set_real_ip_from lines and real_ip_header | the Cloudflare part | keep them if your store is behind Cloudflare (your DNS records in Cloudflare show an orange cloud, Proxied), delete them if it isn't |
| client_max_body_size 64m | near the top of the server block | the largest upload you need, the same as post_max_size in PHP (`grep post_max_size /etc/php/8.3/fpm/php.ini` shows it; use your PHP version) |

The names example_com and example_com_login are only labels for the cache and the login limit inside nginx, so you don't have to change them. If you do rename them, rename them on all four lines that use them.

## Step 4: Create the cache folder, enable the site and reload nginx

The nginx packages of Ubuntu and Debian don't create /var/cache/nginx, and nginx creates the example.com folder itself, but not the /var/cache/nginx folder above it. When I tested the file on Ubuntu 26.04 right after the install, nginx -t failed with `mkdir() "/var/cache/nginx/example.com" failed (2: No such file or directory)` until that folder existed. So **create the folder first**, then link the file into sites-enabled and test it. Use your own file name in the ln -s line:

```bash
root@server:~# mkdir -p /var/cache/nginx
root@server:~# ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
root@server:~# nginx -t && systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```

With nginx from nginx.org, skip the ln -s, because nginx already reads the file from conf.d. If you followed my Ubuntu or Debian guide, sites-enabled may already have a link with the same name, and ln -s prints "File exists". That's fine: the old link points at the same file.

## Step 5: Check that the cache works and the cart is never cached

Put your own product address into these checks and run them after every change to the file:

1. **Syntax:** `nginx -t` says "syntax is ok" and "test is successful".
2. **Cache:** `curl -sI https://example.com/product/your-product/ | grep -i x-cache-status`, run twice, says MISS and then HIT.
3. **Cart:** `curl -sI https://example.com/cart/ | grep -i x-cache-status` says BYPASS, every time.
4. **Cart cookie:** `curl -sI -H 'Cookie: woocommerce_items_in_cart=1' https://example.com/product/your-product/ | grep -i x-cache-status` says BYPASS.
5. **Uploads:** put a one-line PHP file into wp-content/uploads, request it with curl and expect 403, then delete the PHP file (the three commands are after this list).

```bash
echo '<?php echo "PHP ran";' > /var/www/mystore.com/wp-content/uploads/test.php
curl -s -o /dev/null -w '%{http_code}\n' https://mystore.com/wp-content/uploads/test.php
rm /var/www/mystore.com/wp-content/uploads/test.php
```

When the second check says HIT and the fourth says BYPASS, nginx sends your product pages from its cache, and every shopper keeps their own cart.

## Step 6: Clear the cache when a price or stock changes

A page cache keeps a saved copy of each page, so a cached page can show an old price. With this file it can show it for up to 60 minutes, the time set in `fastcgi_cache_valid`. I changed the T-shirt's price twice and sold the last one, first with no plugin and then with [Nginx Helper](https://wordpress.org/plugins/nginx-helper/) 2.4.1, a free plugin that deletes a page from nginx's cache when the page changes in WordPress. I tested the plugin with its WooCommerce option off and on:

| What changed | No plugin | Nginx Helper, WooCommerce option off | Nginx Helper, WooCommerce option on |
| --- | --- | --- | --- |
| Price saved in the product editor | old price shown | cache cleared, new price | cache cleared, new price |
| Price saved through the WooCommerce REST API (what apps and stock syncs use) | old price shown | old price shown | cache cleared, new price |
| Last T-shirt sold | still "1 in stock" | still "1 in stock" | cache cleared, "Out of stock" |

With the WooCommerce option off, the database said 0 in stock while the cached page still showed "1 in stock" and an Add to cart button. A new shopper who pressed it got this message from WooCommerce: **You cannot add "Classic Cotton T-Shirt" to the cart because the product is out of stock.**

**The WooCommerce option is off when you install the plugin**, so you'll have to turn it on:

1. In WordPress, install and activate Nginx Helper, then open **Settings > Nginx Helper**.
2. Turn on **Enable Purge**, choose **nginx Fastcgi cache** as the caching method and **Delete local server cache files** as the purge method.
3. Under **WooCommerce Options**, turn on **Purge product cache on updates** and press **Save All Changes**.
4. Add `define( 'RT_WP_NGINX_HELPER_CACHE_PATH', '/var/cache/nginx/example.com/' );` to wp-config.php, anywhere above `/* That's all, stop editing! Happy publishing. */`, with your own cache folder from step 3. If wp-config.php already has this line (from my Ubuntu guide), change the path in it instead of adding a second line.

The plugin deletes the cache files itself, so PHP needs permission to delete them. With the nginx packages of Ubuntu and Debian, and with the `user www-data;` line on nginx from nginx.org, nginx and PHP-FPM both run as www-data, and the deletion works. If you don't want a plugin, set a shorter time in `fastcgi_cache_valid`: a page can then show an old price for a shorter time, but PHP builds the page more often.

## Pretty permalinks and the PHP-FPM socket

The `try_files $uri $uri/ /index.php?$args` line makes WordPress addresses work. nginx first looks for a real file at that address (a picture, a stylesheet), then for a folder, and when neither exists it passes the request to WordPress's index.php, together with the query string.

That's how /product/classic-cotton-t-shirt/ reaches WooCommerce, although there's no such folder on the disk. `index index.php` makes nginx run a folder's own index.php for addresses such as /wp-admin/.

The PHP location sends every request for a .php file to **PHP-FPM**, the pool of PHP processes, through the socket from step 1. `fastcgi_param SCRIPT_FILENAME` tells PHP which file to run. The extra `try_files $uri =404` refuses a .php address that doesn't exist on the disk. Without it, a request like /wp-includes/images/w-logo-blue.png/x.php could make PHP run a picture as code.

```bash
root@server:~# curl -s -o /dev/null -w '%{http_code}\n' https://example.com/product/classic-cotton-t-shirt/
200
root@server:~# curl -s -o /dev/null -w '%{http_code}\n' https://example.com/no-such-page/
404
root@server:~# curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/no-such-image.webp
404
root@server:~# curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-includes/images/w-logo-blue.png/x.php
404
```

The first 404 is WordPress's own "page not found". The other two come from nginx, which didn't pass those requests to PHP. If the socket in fastcgi_pass is wrong, or PHP-FPM isn't running, every PHP page without a cached copy returns a 502 Bad Gateway error. [My 502 Bad Gateway guide](https://carthacker.com/hosting/502-bad-gateway-nginx/) explains each cause, with the line nginx writes into the error log for it.

## Block PHP files in the uploads folder

WordPress lets editors upload files into wp-content/uploads, and plugins save files there too. If an attacker puts a PHP file into uploads and nginx runs it, the attacker controls your store. That's why the file refuses every .php address in uploads.

The position of this rule in the file is important. **nginx checks regex locations in the order they appear and uses the first one that matches** ([nginx documentation on location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location)), and both the uploads rule and the PHP location match a test.php in uploads. So the uploads rule must come before the PHP location.

To show the difference, I put a one-line PHP file into uploads and requested it twice. The first time, I used a test copy of nginx (a second nginx in my lab with a short config file of its own) where the uploads rule came after the PHP location, as it would be if someone added the rule at the end of the file. The second time, I used the file from this article:

```bash
# the uploads rule after the PHP location
root@server:~# curl -s https://example.com/wp-content/uploads/2026/10/test.php
PHP ran inside uploads
# the file from this article
root@server:~# curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/2026/10/test.php
403
```

The [WordPress handbook page on nginx](https://developer.wordpress.org/advanced-administration/server/web-server/nginx/) recommends the same test with a real PHP file in uploads. I'd run it after every change to the file (it's check 5 in step 5), then delete the test file.

## Browser cache settings for static files

The two static locations match files by their extension. `expires` tells the browser how long it can keep a file without asking the server again (nginx sends an Expires and a Cache-Control: max-age header), and `access_log off` keeps images, scripts and fonts out of the access log, so the log shows only the pages people opened.

Why is a whole year safe for CSS and JavaScript? WordPress adds a version to their addresses. Every stylesheet and script on my product page ended in ?ver=, like jquery.min.js?ver=3.7.1, so a new version gets a new address and the browser downloads it again. Images get 30 days, because an image optimization plugin can replace a file and keep the same name.

```bash
root@server:~# curl -sI https://example.com/wp-includes/css/dist/block-library/style.min.css
HTTP/2 200
content-type: text/css
expires: Thu, 28 Oct 2027 00:17:46 GMT
cache-control: max-age=31536000
x-content-type-options: nosniff
...
root@server:~# curl -sI https://example.com/wp-content/uploads/woocommerce-placeholder.webp | grep -i cache-control
cache-control: max-age=2592000
```

The response also has the nosniff header, which comes from the server level of the file. It tells the browser to use the file type the server sends, so a picture or a text file can't run as a script. The Strict-Transport-Security header (HSTS), set at the same level, tells browsers to use HTTPS for the next year.

nginx sends both headers with static files only because the static locations use `expires` and have no add_header lines of their own. **nginx copies add_header lines into a location only when the location has none** ([nginx documentation on add_header](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header)). On a test copy of nginx I put `add_header Cache-Control "public, max-age=31536000";` inside the CSS location instead:

```bash
root@server:~# curl -sI https://example.com/wp-includes/css/dist/block-library/style.min.css
HTTP/2 200
cache-control: public, max-age=31536000
```

The security headers were missing from every stylesheet. That's why all three add_header lines in the file are at the server level, including X-Cache-Status. On static files X-Cache-Status has no value, and nginx doesn't send a header without a value. nginx 1.29.3 added `add_header_inherit merge;`, which changes this rule, but the Ubuntu and Debian packages are older than that.

## Gzip compression settings

Gzip compresses text before the server sends it. The HTML of my T-shirt page went from 142,826 bytes to 25,690, and WordPress's block library stylesheet got smaller by about the same percentage:

```bash
root@server:~# curl -s -o /dev/null -w '%{size_download} bytes\n' https://example.com/wp-includes/css/dist/block-library/style.min.css
140829 bytes
root@server:~# curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w '%{size_download} bytes\n' https://example.com/wp-includes/css/dist/block-library/style.min.css
19655 bytes
```

nginx always compresses HTML once gzip is on, and `gzip_types` adds CSS, JavaScript, JSON, XML, SVG and the two font types that aren't already compressed (WOFF2 files are). I'd stay at level 5: level 9 made the page only 492 bytes smaller and needs more CPU time on every request.

`gzip_min_length` skips responses under 256 bytes. `gzip_vary on` adds the Vary: Accept-Encoding header, so caches between your server and the browser keep the compressed copy and the plain copy apart. `gzip_proxied any` is for requests that come through a proxy: without it, nginx doesn't compress anything that arrives with a Via header ([nginx documentation on gzip](https://nginx.org/en/docs/http/ngx_http_gzip_module.html)).

There's no Brotli in the file, because nginx's official Docker image doesn't include a Brotli module (its modules folder has acme, geoip, image_filter, js and xslt). Behind Cloudflare a visitor can still get Brotli or Zstandard, because Cloudflare compresses files on its own servers ([Cloudflare's compression documentation](https://developers.cloudflare.com/speed/optimization/content/compression/)).

Gzip also changes the ETag, the short code a browser sends back to ask whether a file has changed. With gzip on, nginx turns a strong ETag into a weak one with W/ in front, as the [nginx changelog](https://nginx.org/en/CHANGES) has said since version 1.7.3:

```bash
root@server:~# curl -sI https://example.com/wp-includes/css/dist/block-library/style.min.css | grep -i etag
etag: "6aa2dd23-2261d"
root@server:~# curl -sI -H 'Accept-Encoding: gzip' https://example.com/wp-includes/css/dist/block-library/style.min.css | grep -i etag
etag: W/"6aa2dd23-2261d"
```

For static files this isn't a problem: nginx ignores the W/ when it compares the tags, and it returned 304 Not Modified for the W/ version. Code that compares the two tags letter by letter sees two different tags. On a test copy of nginx without the page cache, I tried a small PHP script that sends an ETag and compares the returned tag letter by letter. With gzip on, it sent the full file every time, because the browser sends the tag back with W/ in front.

With the file from this article, PHP never sees that tag: nginx removes the If-None-Match header in the PHP location, because the page cache is on there. If a plugin or your own code reads If-None-Match somewhere else, it should remove the W/ before it compares the tags.

## Nginx page cache (fastcgi_cache): settings and speed test

A **page cache** saves each page after PHP builds it, and the next visitor gets the saved copy without running PHP, WordPress or WooCommerce. nginx has a page cache built in, fastcgi_cache ([nginx documentation on FastCGI](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html)), and the file turns it on with these lines:

- `fastcgi_cache_path` saves the cached pages in /var/cache/nginx/example.com. `keys_zone=example_com:32m` is the memory for the list of cached pages: nginx says one megabyte holds about 8,000 keys, so 32 MB holds about 256,000 pages. `max_size=1g` limits the disk space, `inactive=7d` deletes pages nobody requested for a week, and `use_temp_path=off` writes the files straight into the cache folder.
- `fastcgi_cache_key` decides when two requests count as the same page. Don't change it if you use Nginx Helper from step 6: the plugin needs this key and `levels=1:2` to find the cached files.
- `fastcgi_cache_valid 200 301 60m` keeps pages that returned 200 and permanent redirects in the cache for an hour.
- `fastcgi_cache_use_stale` with `fastcgi_cache_background_update` sends the old copy of an expired page to the next visitor at once and fetches a new copy in the background. It also keeps sending the old copy while PHP doesn't respond.
- `fastcgi_cache_lock on` sends only one request to PHP when many shoppers request the same uncached page at the same moment.
- `add_header X-Cache-Status` at the server level shows what nginx did with each request: HIT, MISS, BYPASS or STALE.

To measure the cache, I emptied it and requested the T-shirt page twice, five times in a row, from a second container in the same Docker network as nginx. The server time is curl's first-byte time minus the connection and the TLS handshake, so it counts only the time the server spent on the page:

| Request | X-Cache-Status | Server time, median of 5 |
| --- | --- | --- |
| Product page, first visit after the cache was emptied | MISS | 197.6 ms |
| Product page, the visit after that | HIT | 0.3 ms |
| Cart page | BYPASS | 182.3 ms |
| Product page, shopper with the T-shirt in the cart | BYPASS | 209.1 ms |
| Product page, logged-in customer | BYPASS | 287.9 ms |

**A cached product page took the server about 650 times less time than a page built by PHP.** Timings in Docker on a desktop computer aren't stable (single runs of the same page differed by tens of milliseconds), so read the table for the size of the difference, not as the numbers your server will give you.

The BYPASS rows are pages that PHP builds on every request. For those pages, the speed depends on the number of PHP-FPM processes and on an object cache ([what Redis changed on my test store](https://carthacker.com/hosting/redis-object-cache-wordpress/)).

I also stopped PHP-FPM to see what `fastcgi_cache_use_stale` does:

```bash
# what curl got with PHP-FPM stopped (systemctl stop php8.3-fpm on a server)
product page:                       HTTP/2 200  x-cache-status: HIT
hoodie page (not in the cache):     HTTP/2 502  x-cache-status: MISS
cart page:                          HTTP/2 502  x-cache-status: BYPASS
checkout page:                      HTTP/2 502  x-cache-status: BYPASS
expired copy, 5-second cache:      HTTP/2 200  x-cache-status: STALE
```

The cached product page still returned 200, and on a test copy of nginx that keeps pages for only 5 seconds, the expired copy was sent with STALE. The cart, the checkout and every page that wasn't in the cache returned 502. So while PHP restarts, shoppers can still open the product pages that are in the cache, but **nobody can buy until PHP runs again**.

> **Store Speed Test:** See how fast your server sends a product page. Paste a product page address and see its three Core Web Vitals scores and the total delay. A free account names the 5 apps that cost the most time, with the fix for your platform. [Test my store](https://carthacker.com/store-speed-test/)

## Pages that must never be cached: cart, checkout and account

The four `if` lines near the top of the server block are the skip rules. They set `$skip_cache` to 1 for every request that must reach PHP. The last two lines of the PHP location use that value: `fastcgi_cache_bypass` tells nginx not to look in the cache for that request, and `fastcgi_no_cache` tells it not to save the response. Each skip rule matches one kind of request:

- POST requests: forms, and Add to cart on a theme without JavaScript. nginx caches only GET and HEAD requests unless you change `fastcgi_cache_methods`, and in my test a POST got no X-Cache-Status line at all, so this rule changes nothing. It only shows nginx's default in the file.
- Any query string: ?add-to-cart=, searches, filters, ?wc-ajax= and ad parameters such as ?utm_source= or ?gclid=. The cost is a slower page for every shopper who comes from an ad, and [my WooCommerce speed tests](https://carthacker.com/hosting/woocommerce-slow/) measured that wait on two real stores.
- Addresses: /cart/, /checkout/ and /my-account/ (the three pages that [WooCommerce's caching guide](https://developer.woocommerce.com/docs/best-practices/performance/configuring-caching-plugins) says must not be cached), /wc-api/ for payment gateway callbacks, wc-ajax= for cart fragments and checkout updates, /wp-admin/, /wp-json/ (the REST API and the Store API that the Cart and Checkout blocks use) and the wp-*.php files such as wp-login.php and wp-cron.php. If you renamed the cart, checkout or account page, put the new address in this line.
- Cookies: wordpress_logged_in_ for anyone who is logged in, WooCommerce's three cart cookies (wp_woocommerce_session_, woocommerce_items_in_cart, woocommerce_cart_hash), wp-postpass_ for password-protected pages and comment_author_ for someone who just left a comment.

```bash
root@server:~# curl -sI https://example.com/cart/ | grep -i x-cache-status
x-cache-status: BYPASS
root@server:~# curl -sI 'https://example.com/product/classic-cotton-t-shirt/?add-to-cart=10' | grep -i x-cache-status
x-cache-status: BYPASS
# the shopper's next page, with the cookies from that answer:
root@server:~# curl -sI -b jar https://example.com/product/classic-cotton-t-shirt/ | grep -i x-cache-status
x-cache-status: BYPASS
# a logged-in admin, then a guest, on the same page:
root@server:~# curl -s -b jar-admin https://example.com/product/classic-cotton-t-shirt/ | grep -c 'id="wpadminbar"'
1
root@server:~# curl -s https://example.com/product/classic-cotton-t-shirt/ | grep -c 'id="wpadminbar"'
0
```

One result surprised me. When the shopper emptied the cart again, WooCommerce deleted two of the cookies, but wp_woocommerce_session_ stayed, so every page that shopper opened still came from PHP. The cookie's Max-Age was 172800 seconds, the **two days** that WooCommerce's guide states, so a shopper who removes the last item gets every page from PHP until that session ends.

There's a reason /wp-json/ is on the list. Two guest requests for /wp-json/wc/store/v1/cart each got a different Cart-Token header, and each token gives access to one shopper's cart. A cache in front of that address would send the first token to everyone.

WooCommerce also protects the cart itself. The cart page came with `cache-control: no-cache, must-revalidate, max-age=0, private`, and on a test copy of nginx with no skip rules at all, the cart still returned MISS three times in a row, because nginx doesn't save a response marked private. So two things keep a cart page out of the cache: **the skip rules in this file and WooCommerce's private header**.

## Don't use fastcgi_ignore_headers with WooCommerce

Some nginx examples have no skip rules and add `fastcgi_ignore_headers Cache-Control Expires Set-Cookie;` to put more pages into the cache. That removes both protections. I set this up on a test copy of nginx and opened an add-to-cart link (the kind you'd put in a newsletter), first as shopper A and then as shopper B:

```bash
# the cart page, second request
root@server:~# curl -sI https://example.com/cart/ | grep -iE 'cache-control|x-cache-status'
cache-control: no-cache, must-revalidate, max-age=0, private
x-cache-status: HIT
# shopper A, then shopper B, open /product/classic-cotton-t-shirt/?add-to-cart=10
shopper A: x-cache-status: MISS, session cookie value t_eb511b530ce61ddfdbfed9...
shopper B: x-cache-status: HIT, session cookie value t_eb511b530ce61ddfdbfed9...
# shopper A adds a hoodie; what shopper B now has in the cart:
root@server:~# curl -s -b jar-B https://example.com/wp-json/wc/store/v1/cart | grep -o '"name":"[^"]*"'
"name":"Classic Cotton T-Shirt"
"name":"Pullover Hoodie"
```

nginx saved the response for shopper A with A's session cookie in it and sent that saved response to shopper B. B's browser stored A's cookie, and from then on WooCommerce treated B as A: the hoodie that A added a moment later appeared in B's cart. Everyone else who opened that link before the cached copy expired would've shared the same cart.

By default nginx doesn't cache a response that sets a cookie, and the fastcgi_ignore_headers line turns exactly that rule off. **Don't put fastcgi_ignore_headers into a WooCommerce config.** With the file from this article, the same link returned BYPASS twice and gave each shopper their own session cookie.

## Deny rules for wp-config backups, hidden files and XML-RPC

On Apache, WordPress and its plugins protect some files with .htaccess. nginx doesn't read .htaccess files, so each of those rules has to be in the nginx file. The most important rule blocks wp-config.php backups.

On a test copy of nginx without the wp-config rule and the backup rule, a wp-config.php.bak (a copy that often stays on the server after someone edits the file) downloaded as a file with **the database password inside**:

```bash
root@server:~# curl -s https://example.com/wp-config.php.bak | grep DB_
define( 'DB_NAME', 'wp' );
define( 'DB_USER', 'wp' );
define( 'DB_PASSWORD', 'lab4013wp' );
define( 'DB_HOST', 'db' );
...
```

The PHP location doesn't handle that file, because the name doesn't end in .php, so nginx sends it as a normal file. Here's what each deny rule in the file blocks, with the response I got when the rule was in place:

- `location = /xmlrpc.php`: XML-RPC, WordPress's old remote API, which accepts usernames and passwords just like wp-login.php (403).
- `location ~ /\.(?!well-known/)`: hidden files such as .htaccess, .env and .git/config (403 for all three). The /.well-known/ folder stays open, because Let's Encrypt reads its check file there, and it follows the redirect from port 80 to HTTPS to read it ([Let's Encrypt's challenge types](https://letsencrypt.org/docs/challenge-types/)). A request for my test file got 200.
- `location ~* ^/wp-config`: wp-config.php and every copy of it, such as wp-config.php.bak and wp-config.php~ (403). WordPress reads the real file from the disk, so no browser ever needs it.
- `\.(?:bak|old|orig|save|swp|sql|log)$`: backups, database dumps and logs, including wp-content/debug.log when debug logging is on (403).
- `readme.html` and `license.txt` (403). This rule doesn't do much: the readme of WordPress 7.1.3 doesn't show the version, and every page already shows it in a generator meta tag, so the rule only stops bots that look for these two files to find WordPress sites.
- `location ^~ /wp-content/uploads/woocommerce_uploads/` with `internal`: downloadable products. WooCommerce protects that folder with an .htaccess file, and its [nginx instructions](https://developer.woocommerce.com/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory/) say to add an internal location before the static file rules. A direct link returned 404, and customers still get the file through WooCommerce's download links.
- `location ^~ /wp-content/upgrade/`: the folder where WordPress unpacks a core update. I added this rule on October 31, 2026, after WordPress 7.1.4 fixed CVE-2026-119977: versions 4.7 to 7.1.3 leave a working copy of the installer in that folder for about 2 seconds during an update, and attackers send requests to it. With the rule, every request to the folder got 403 on my test server, and the update still finished. [Why WordPress 4.7 to 7.1.3 needs this rule](https://carthacker.com/hosting/wordpress-7-1-4/).

Blocking xmlrpc.php can break Jetpack and the WordPress mobile apps. [Jetpack's documentation](https://jetpack.com/support/getting-started-with-jetpack/known-issues/) says Jetpack "depends on the XML-RPC connection in WordPress", and a [WordPress Trac ticket](https://core.trac.wordpress.org/ticket/62789) says XML-RPC is "still in use by the WordPress mobile apps for Android and iOS" to sign in to self-hosted sites.

If you use Jetpack, allow xmlrpc.php only for Jetpack's IP ranges. They're on [Jetpack's IP list](https://jetpack.com/support/how-to-add-jetpack-ips-allowlist/), which changes from time to time. Replace the deny block with this one:

```nginx
    location = /xmlrpc.php {
        allow 122.248.245.244/32;
        allow 54.217.201.243/32;
        allow 54.232.116.4/32;
        allow 192.0.80.0/20;
        allow 192.0.96.0/20;
        allow 192.0.112.0/20;
        allow 195.234.108.0/22;
        allow 192.0.64.0/18;
        deny all;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
```

The allow lines check the visitor's real IP address, so they also work behind Cloudflare when the real IP lines are in the file. I tested the block with the real IP lines in the file: a request whose CF-Connecting-IP was a Jetpack IP address got "XML-RPC server accepts POST requests only.", and any other IP address got 403.

## Login limit for wp-login.php and the upload size limit

`limit_req_zone` counts requests per visitor IP address (`$binary_remote_addr`, the IP address in a short form) in a 10 MB zone. On a 64-bit server nginx stores about 8,000 IP addresses per megabyte ([nginx documentation on limit_req](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)), so this zone holds about 80,000.

The rate allows one request every ten seconds on average, and `burst=5 nodelay` accepts five more requests at once before nginx starts to refuse them. `limit_req_status 429` replaces nginx's default response, 503 (service unavailable), which looks like an outage in your logs. 429 means "too many requests", which describes what happened.

The wp-login.php location needs its own three fastcgi lines. nginx uses an exact location (one with =) before any regex location, so the PHP location never handles wp-login.php, and without those three lines nginx wouldn't pass the login page to PHP at all.

```bash
root@server:~# for i in $(seq 12); do curl -s -o /dev/null -w '%{http_code} ' -d 'log=admin&pwd=guess' https://example.com/wp-login.php; done
200 200 200 200 200 200 429 429 429 429 429 429
root@server:~# for i in $(seq 12); do curl -s -o /dev/null -w '%{http_code} ' https://example.com/my-account/; done
200 200 200 200 200 200 200 200 200 200 200 200
```

Your customers won't reach this limit in normal use. WooCommerce's login form is on My Account, which isn't limited, and My Account returned 200 for all 12 requests. wp-login.php is where you and your staff log in, and where bots try to guess passwords.

`client_max_body_size 64m` sets the largest request nginx accepts, for product CSV imports, theme and plugin zips and large product photos. nginx's default is 1 MB. A 70 MB upload got **413 Request Entity Too Large** from nginx before the request reached PHP. PHP has two size limits of its own (post_max_size for the whole request, upload_max_filesize for each file). Keep this nginx value equal to post_max_size or a little higher, so nginx won't refuse an upload that PHP would accept. [How to fix 413 Request Entity Too Large](https://carthacker.com/hosting/413-request-entity-too-large/) tests the three limits one at a time.

## Real visitor IP addresses behind Cloudflare

When Cloudflare proxies your store, every connection to nginx comes from a Cloudflare server, and Cloudflare sends the visitor's own IP address in the **CF-Connecting-IP** header. The `set_real_ip_from` lines list the Cloudflare IP ranges that nginx trusts, and `real_ip_header` names the header ([nginx documentation on realip](https://nginx.org/en/docs/http/ngx_http_realip_module.html) and [Cloudflare's guide to restoring visitor IPs](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/)).

To test this without Cloudflare, I gave one test client an IP address from 197.234.240.0/22, one of Cloudflare's ranges, and another client an ordinary IP address. Both sent the same header to a small PHP file that prints the IP address PHP sees:

```bash
# from an address in Cloudflare's range
root@server:~# curl -s -H 'CF-Connecting-IP: 203.0.113.50' 'https://example.com/ip-test.php?x=1'
REMOTE_ADDR seen by PHP: 203.0.113.50
# from an ordinary address, same header
root@server:~# curl -s -H 'CF-Connecting-IP: 203.0.113.50' 'https://example.com/ip-test.php?x=1'
REMOTE_ADDR seen by PHP: 10.213.13.50
```

So **nginx uses the header only when the request comes from a Cloudflare IP address**, and nobody else can fake an IP address with it. The access log, WordPress and every plugin that reads the visitor's IP address get the real one.

The login limit uses the real IP address too. Through the Cloudflare range, one visitor got 429 after six tries while a second visitor still got 200. Without the set_real_ip_from lines, nginx would count everyone who comes through the same Cloudflare server as one visitor, and the limit would block you together with the bot that's guessing passwords.

Cloudflare's list of IP ranges changes. [Cloudflare's IP page](https://www.cloudflare.com/ips/) shows the history, with a range removed in 2021 and another added in 2023, and Cloudflare's guide says the list "needs to be updated regularly". This command prints new set_real_ip_from lines to replace the old ones:

```bash
root@server:~# { curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6; } | sed '/^$/d; s/.*/    set_real_ip_from &;/'
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
...
```

## HTTP/2 and the SSL certificate

`listen 443 ssl` with `http2 on` serves the store over HTTPS with HTTP/2, so the browser can download a page's stylesheets, scripts and images over one connection. The browser and the server choose the protocol during the TLS handshake (ALPN), and you can check it from any computer with curl:

```bash
root@server:~# curl -sv -o /dev/null https://example.com/ 2>&1 | grep ALPN
* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2
root@server:~# curl -sI http://example.com/product/classic-cotton-t-shirt/ | grep -iE '^HTTP|^location'
HTTP/1.1 301 Moved Permanently
Location: https://example.com/product/classic-cotton-t-shirt/
```

Both certificate lines (ssl_certificate for the certificate, ssl_certificate_key for its key) point to the folder where Certbot saves a Let's Encrypt certificate (my test server had a self-signed certificate at the same path). If you get the certificate with `certbot certonly`, Certbot doesn't change this file, so nginx has to be reloaded after each renewal. [My Certbot guide](https://carthacker.com/hosting/certbot-nginx/) shows how to do that automatically and what changes when your store is behind Cloudflare.

## Sources

- nginx documentation: [ngx_http_fastcgi_module](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html), [location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location), [ngx_http_headers_module](https://nginx.org/en/docs/http/ngx_http_headers_module.html), [ngx_http_gzip_module](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), [ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), [ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html), [ngx_http_v2_module](https://nginx.org/en/docs/http/ngx_http_v2_module.html), [Linux packages](https://nginx.org/en/linux_packages.html) and the [changelog](https://nginx.org/en/CHANGES) (1.7.3, 1.25.1, 1.29.3).
- WordPress Developer Resources: [Nginx](https://developer.wordpress.org/advanced-administration/server/web-server/nginx/), last updated October 14, 2026. WordPress Trac: [ticket 62789](https://core.trac.wordpress.org/ticket/62789).
- WooCommerce: [How to configure caching plugins for WooCommerce](https://developer.woocommerce.com/docs/best-practices/performance/configuring-caching-plugins) and [Using NGINX server to protect your uploads directory](https://developer.woocommerce.com/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory/).
- Cloudflare: [IP ranges](https://www.cloudflare.com/ips/), [Restoring original visitor IPs](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/) and [Content compression](https://developers.cloudflare.com/speed/optimization/content/compression/).
- Jetpack: [Known issues](https://jetpack.com/support/getting-started-with-jetpack/known-issues/) and [How to add Jetpack IPs to an allowlist](https://jetpack.com/support/how-to-add-jetpack-ips-allowlist/).
- Let's Encrypt: [Challenge types](https://letsencrypt.org/docs/challenge-types/). Nginx Helper on [WordPress.org](https://wordpress.org/plugins/nginx-helper/).

---

## Cite this guide

Wadowski, A. (2026, October 31). *How to Set Up Nginx for WooCommerce: A Complete Config Example*. CartHacker. https://carthacker.com/hosting/nginx-wordpress-config/

## Free tools from CartHacker

- [Store Speed Test](https://carthacker.com/store-speed-test/): Find the apps that slow your product page down.
- [Core Web Vitals Monitoring](https://carthacker.com/plugins/core-web-vitals-monitoring/): Monitor your store's Core Web Vitals every day, for free.
- [Trust Seals & Badges](https://carthacker.com/trust-badges/): Show shoppers it's safe to buy, right under Add to cart.
- [QR Code Generator](https://carthacker.com/qr-code-generator/): Make a QR code you can still change after printing.
- [PDF Tools](https://carthacker.com/pdf-tools/): Free online PDF tools that run in your browser: merge, split, convert, sign and protect PDFs. 39 tools, no upload, no watermark, no sign-up.
- [CartHacker Premium](https://carthacker.com/premium/): one subscription, monthly or yearly, that upgrades every tool at once:
  - 50 trust badges in 30 languages, switched automatically
  - 180 days of Core Web Vitals, per page
  - All 39 PDF tools, unlimited and never uploaded
  - 200 QR codes, tracked and editable after printing
  - No ads on any guide or tool
  - Every new guide 2 weeks before the public
  - Product Feed Manager and new tools included
- Every quota and price, tool by tool: https://carthacker.com/llms.txt
