A slow WooCommerce store usually has the same few causes: PHP compiling the same files on every request, too few or too many PHP-FPM workers, a database that does not fit in memory, and every anonymous page being rendered from scratch. In this tutorial you will fix each of those on an existing WooCommerce site running on Nginx, PHP-FPM and MySQL on Ubuntu 24.04. You will measure the store before and after, so every change can be verified.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM (4 GB or more for stores with thousands of products).
  • A non-root user with sudo privileges.
  • A working WordPress and WooCommerce site served by Nginx and PHP 8.3 FPM (the Ubuntu 24.04 default), with MySQL 8.0 or MariaDB. This guide assumes the site lives in /var/www/your_domain and its Nginx server block is /etc/nginx/sites-available/your_domain.
  • A recent backup of the site files and database. You will change server configuration and WordPress settings.

Replace your_domain with your real domain everywhere below.

Step 1 - Measuring a baseline

Before changing anything, record how long the server takes to generate a page. curl can report the time to first byte (TTFB), which is dominated by PHP and the database:

for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{time_starttransfer}\n" https://your_domain/shop/; done
0.912
0.874
0.901
0.889
0.866

Run it against the home page, the shop page and a product page, and note the values. Anything consistently above 0.5 seconds for an anonymous visitor has room for improvement. Keep this command at hand: you will run it again after each step.

Also install WP-CLI, which you will use to manage caches and plugins from the shell. Download the official Phar, check that it runs and install it system wide:

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php wp-cli.phar --info
sudo install -m 0755 wp-cli.phar /usr/local/bin/wp

Run WP-CLI as the user that owns the WordPress files (usually www-data) and confirm it sees your site:

sudo -u www-data wp --path=/var/www/your_domain plugin list --status=active

Step 2 - Tuning OPcache

OPcache stores compiled PHP bytecode in shared memory, so WordPress, WooCommerce and your plugins are not recompiled on every request. It is enabled by default, but the default memory size is small for a WooCommerce site with many plugins.

Create a drop-in file for PHP-FPM:

sudo nano /etc/php/8.3/fpm/conf.d/99-woocommerce.ini

Add the following settings:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60

memory_limit=256M
realpath_cache_size=4096K
realpath_cache_ttl=600

revalidate_freq=60 makes PHP check for changed files at most once a minute, which saves thousands of stat calls while still picking up plugin updates. memory_limit=256M matches what WooCommerce recommends for its admin screens and reports.

Restart PHP-FPM and confirm the values are active:

sudo systemctl restart php8.3-fpm
php-fpm8.3 -i | grep -E "opcache.memory_consumption|opcache.max_accelerated_files"
opcache.max_accelerated_files => 20000 => 20000
opcache.memory_consumption => 256 => 256

Step 3 - Sizing the PHP-FPM pool

Each PHP-FPM worker handles one request at a time. With too few workers, requests queue during traffic spikes; with too many, the server runs out of RAM and starts swapping, which is far worse. Size the pool from real memory usage.

After the site has served some traffic, measure the average memory per worker:

ps --no-headers -o rss -C php-fpm8.3 | awk '{ sum += $1; n++ } END { printf "%d workers, %.0f MB average\n", n, sum / n / 1024 }'
6 workers, 78 MB average

Calculate pm.max_children as the RAM you can give PHP divided by that average. On a 4 GB server where MySQL and Redis need about 1.5 GB and the system about 0.5 GB, PHP can use roughly 2 GB: 2048 / 80 gives about 25 workers.

Edit the default pool:

sudo nano /etc/php/8.3/fpm/pool.d/www.conf

Find the process manager settings and set them according to your calculation:

pm = dynamic
pm.max_children = 25
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500

pm.max_requests recycles each worker after 500 requests, which contains slow memory leaks in plugins.

Test the configuration and restart the service:

sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm
NOTICE: configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful

If the log ever shows server reached pm.max_children setting, you are hitting the limit. Check it with:

sudo grep max_children /var/log/php8.3-fpm.log

Raise the limit only if free -m shows spare memory; otherwise the server needs more RAM.

Step 4 - Tuning MySQL and cleaning the database

The most important MySQL setting is the InnoDB buffer pool: the memory where table data and indexes are cached. The default of 128 MB is too small for most stores, so queries end up reading from disk.

Check how large your data actually is:

sudo mysql -e "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024) AS size_mb FROM information_schema.tables WHERE engine = 'InnoDB';"
+---------+
| size_mb |
+---------+
|     640 |
+---------+

Set the buffer pool a little above that size, within the memory budget from Step 3. Create a drop-in file:

sudo nano /etc/mysql/mysql.conf.d/99-woocommerce.cnf
[mysqld]
innodb_buffer_pool_size = 1G
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1

If you use MariaDB, place the same file in /etc/mysql/mariadb.conf.d/ instead. The slow query log records any query that takes longer than one second, which is how you find plugins that hammer the database.

Restart MySQL and check the value:

sudo systemctl restart mysql
sudo mysql -e "SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mb;"
+----------------+
| buffer_pool_mb |
+----------------+
|  1024.00000000 |
+----------------+

Next, check how much data WordPress loads on every request. Options marked as autoloaded are read from wp_options on each page load, and abandoned plugins often leave megabytes behind:

sudo -u www-data wp --path=/var/www/your_domain db query "SELECT ROUND(SUM(LENGTH(option_value)) / 1024) AS autoload_kb FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');"

If the result is above about 1000 KB, list the largest entries and review them:

sudo -u www-data wp --path=/var/www/your_domain db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto') ORDER BY bytes DESC LIMIT 15;"

Only delete options that belong to plugins you have removed. Adjust the wp_ prefix if your installation uses a different one.

Finally, make sure WooCommerce uses High-Performance Order Storage (HPOS), which keeps orders in dedicated tables instead of the generic posts tables. It is the default for new stores. In the WordPress admin, go to WooCommerce > Settings > Advanced > Features and check that High-performance order storage is selected. On older stores, enable compatibility mode first, let the synchronization finish, and then switch.

Step 5 - Adding a Redis object cache

WordPress caches objects such as options, product data and query results only for the duration of a single request. A persistent object cache keeps them in Redis between requests, which removes a large share of database queries, especially in the admin, the cart and the checkout, which cannot be page cached.

Install Redis and the PHP extension:

sudo apt install redis-server php8.3-redis
sudo systemctl restart php8.3-fpm

Ubuntu's Redis listens only on localhost by default. Set a memory limit and an eviction policy so the cache never grows without bound:

sudo nano /etc/redis/redis.conf

Find and set these two directives:

maxmemory 256mb
maxmemory-policy allkeys-lru

Restart Redis and check that it answers:

sudo systemctl restart redis-server
redis-cli ping
PONG

Install and enable the Redis Object Cache plugin, which provides the object-cache.php drop-in:

sudo -u www-data wp --path=/var/www/your_domain plugin install redis-cache --activate
sudo -u www-data wp --path=/var/www/your_domain redis enable
sudo -u www-data wp --path=/var/www/your_domain redis status
Status: Connected
Client: PhpRedis
Drop-in: Valid

With the defaults it connects to 127.0.0.1:6379, which is where Ubuntu's Redis listens. If you host several WordPress sites on the same Redis, give each one a unique prefix by adding define( 'WP_REDIS_PREFIX', 'your_domain' ); to its wp-config.php.

Step 6 - Caching anonymous pages with Nginx FastCGI cache

For visitors who are not logged in and have an empty cart, the shop, category and product pages are the same for everyone. Nginx can store the HTML generated by PHP and serve it directly, without touching PHP or MySQL. The cart, checkout, account pages and any visitor with items in the cart must always bypass the cache.

Define the cache zone in the http context:

sudo nano /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/woocommerce levels=1:2 keys_zone=WOOCOMMERCE:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;

Now open your site's server block:

sudo nano /etc/nginx/sites-available/your_domain

Inside the server block, before the location blocks, add the rules that decide when to skip the cache:

set $skip_cache 0;

if ($request_method = POST) {
    set $skip_cache 1;
}
if ($query_string != "") {
    set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/wp-json/|/xmlrpc.php|wp-.*\.php|/feed/|sitemap(_index)?\.xml|/cart/|/checkout/|/my-account/|/wc-api/") {
    set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_") {
    set $skip_cache 1;
}

The query string rule also excludes ?add-to-cart= and ?wc-ajax= requests. If your cart, checkout or account pages use translated slugs, add them to the $request_uri pattern.

Then, in your existing location ~ \.php$ block, add the cache directives after the fastcgi_pass line:

fastcgi_cache WOOCOMMERCE;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-FastCGI-Cache $upstream_cache_status;

A 10 minute lifetime keeps prices and stock levels reasonably fresh while still absorbing most traffic.

Test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Request the shop page twice and look at the new header:

curl -sI https://your_domain/shop/ | grep -i x-fastcgi-cache
curl -sI https://your_domain/shop/ | grep -i x-fastcgi-cache
X-FastCGI-Cache: MISS
X-FastCGI-Cache: HIT

Check that the cart is never cached:

curl -sI https://your_domain/cart/ | grep -i x-fastcgi-cache
X-FastCGI-Cache: BYPASS

Finally, add an item to the cart in a private browser window and browse a few product pages: the header should show BYPASS and the mini cart must show your item. When you change prices or products and need the change visible immediately, clear the cache:

sudo find /var/cache/nginx/woocommerce -type f -delete

Now run the baseline command from Step 1 again. Cached pages typically answer in well under 50 ms.

Step 7 - Running WP-Cron and Action Scheduler from system cron

By default WordPress runs scheduled tasks (WP-Cron) during page loads, which slows down random visitors and, with page caching, may not run reliably at all. WooCommerce relies on these tasks through Action Scheduler for emails, webhooks, stock holds and scheduled sales.

Disable the page-load trigger by adding this line to /var/www/your_domain/wp-config.php, above the line that says /* That's all, stop editing! */:

define( 'DISABLE_WP_CRON', true );

Then run due events every minute from the system crontab of the www-data user:

sudo crontab -u www-data -e
* * * * * /usr/local/bin/wp --path=/var/www/your_domain cron event run --due-now --quiet

After a few minutes, confirm that events are being processed and that no actions are piling up:

sudo -u www-data wp --path=/var/www/your_domain cron event list --fields=hook,next_run_relative | head

In the admin, Tools > Scheduled Actions should show few or no Past-due actions.

Troubleshooting

Customers see someone else's cart or stay logged out. A page was cached for a visitor with a session. Check that the cookie rule in Step 6 contains wp_woocommerce_session_ and woocommerce_items_in_cart, and that your cart and checkout slugs are in the URI rule. Then clear the cache.

wp redis status shows Not connected. Check that Redis is running with systemctl status redis-server and that the php8.3-redis extension is loaded with php -m | grep redis, then restart PHP-FPM.

502 Bad Gateway after the PHP-FPM changes. Read sudo journalctl -u php8.3-fpm -n 50. A typo in www.conf prevents the pool from starting; sudo php-fpm8.3 -t points to the line.

The server starts swapping. The sum of pm.max_children times the worker size, the InnoDB buffer pool and Redis maxmemory exceeds the RAM. Lower one of them or move to a larger plan.

Conclusion

Your WooCommerce store now runs with a properly sized OPcache and PHP-FPM pool, a MySQL buffer pool that fits the data, a persistent Redis object cache, full-page caching for anonymous visitors and reliable background jobs. As next steps, serve images in WebP or AVIF and put a CDN in front of static assets, review the slow query log after a busy day, and load test the checkout with a tool such as k6 before a big sale.