Start from the machine you actually have
Every setting below depends on two numbers: how much RAM the server has, and how much of it MySQL and PHP each get. Copying a config without doing that arithmetic is why stores start swapping the moment a campaign begins. Write down three things first.
free -m
# average resident memory per PHP-FPM worker
ps --no-headers -o rss -C php-fpm8.2 | awk '{s+=$1; n++} END {printf "avg %.0f MB over %d workers\n", s/n/1024, n}'
# how big is the InnoDB data set?
mysql -e "SELECT ROUND(SUM(data_length+index_length)/1024/1024) AS innodb_mb
FROM information_schema.tables WHERE engine='InnoDB';"On OpenCart 3.x and 4.x a worker usually settles between 60 and 120 MB, depending on how many extensions load per request. A store with sixty extensions is not the same machine as a store with six. The numbers below come from a mid-size home-textile store we host: 8 GB, 4 vCPU, running store, database and Redis on one box.
- Example VPS
- 8 GB / 4 vCPU
- Reserved for MySQL
- 2.5 GB
- Left for PHP-FPM
- 4 GB
The Nginx server block
SEO URLs are the part people get wrong most often. OpenCart does not expect the web server to route anything — it wants the whole path delivered as the _route_ query parameter, exactly as the Apache .htaccess does. One try_files line handles it, identically on 3.x and 4.x.
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+; older builds: listen 443 ssl http2;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php;
client_max_body_size 32m; # admin product image uploads
# OpenCart SEO URLs
location / {
try_files $uri $uri/ /index.php?_route_=$uri;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/opencart.sock;
fastcgi_read_timeout 120s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
}
location ~* \.(?:css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
try_files $uri =404;
}
# never execute PHP from writable directories
location ~* ^/(image|system/storage)/.*\.php$ { deny all; }
location ^~ /system/storage/ { deny all; }
location ~ /\.(?!well-known) { deny all; }
}The deny rules matter more than they look. After finding an upload hole, the first thing an attacker does is drop a .php file into image/; if PHP cannot run there, the hole is a nuisance instead of a breach. And system/storage holds logs, sessions and downloadable products — on Apache a .htaccess protects it, which Nginx never reads.
Locking the admin directory to known addresses takes ten minutes and holds one trap. The ^~ modifier makes Nginx skip regex locations entirely, so PHP under that path stops being executed and starts being downloaded as plain text — config.php included. Repeat the handler inside the block.
limit_req_zone $binary_remote_addr zone=ocadmin:10m rate=30r/m;
location ^~ /admin/ {
allow 203.0.113.10; # office
allow 198.51.100.0/24; # vpn
deny all;
limit_req zone=ocadmin burst=20 nodelay;
location ~ \.php$ { # required: ^~ disabled the outer regex block
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/opencart.sock;
fastcgi_read_timeout 300s; # exports and bulk edits take time
}
}Compression and cache headers
OpenCart pages are text-heavy: HTML, a large stylesheet, several JavaScript bundles and JSON from the AJAX cart. Level 5 is the sensible middle; higher buys little at these sizes.
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 5;
gzip_types text/css text/xml application/javascript application/json
image/svg+xml application/rss+xml;
# with the ngx_brotli module compiled in
brotli on;
brotli_comp_level 5;
brotli_types text/css application/javascript application/json image/svg+xml;Two details people trip over: text/html is always gzipped and does not belong in gzip_types, and already-compressed formats — JPEG, WebP, woff2 — belong in neither list. If you compress at the web server, leave OpenCart's own output compression off.
The PHP-FPM pool and the pm.max_children arithmetic
When a busy OpenCart store “goes down”, the cause is rarely CPU. It is a pool out of workers: requests queue behind the slowest page on the site, Nginx returns 502s, and the graphs show a bored machine. The fix is not a bigger number, it is the right number.
Memory available to PHP, divided by average worker size. On the example server that is roughly 4 GB after MySQL, Redis and the OS, with workers averaging 90 MB — 45 children, rounded down. Setting it to 200 serves customers until memory runs out and the kernel starts killing processes.
; /etc/php/8.2/fpm/pool.d/opencart.conf
[opencart]
user = www-data
group = www-data
listen = /run/php/opencart.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 45
pm.start_servers = 10
pm.min_spare_servers = 8
pm.max_spare_servers = 16
pm.max_requests = 500
pm.status_path = /fpm-status
request_terminate_timeout = 120s
slowlog = /var/log/php-fpm/opencart-slow.log
request_slowlog_timeout = 5s
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32MTwo of those lines do more diagnostic work than most dashboards. pm.max_requests recycles a worker after five hundred requests, so a leak in one extension cannot grow unbounded. request_slowlog_timeout writes a PHP backtrace for anything over five seconds — usually how we find the module calling an external API on every category page.
- pm = dynamic for a normal storefront with uneven traffic.
- pm = ondemand for staging and low-traffic sites; idle workers release memory.
- pm = static only on a dedicated box, after measuring worker size.
- Watch the listen queue on /fpm-status: anything above zero is a customer waiting.
OPcache and the realpath cache
OpenCart is thousands of small PHP files, and 4.x adds compiled Twig templates on top. Without OPcache every request re-reads and re-parses them.
; php.ini (fpm)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=30000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
opcache.save_comments=1
realpath_cache_size=4096k
realpath_cache_ttl=600Size max_accelerated_files against reality: count the files with find . -name "*.php" | wc -l and set the limit above that (PHP rounds it up to a prime). A full OPcache warns nobody — it silently stops caching new files, and the extension you installed last runs uncached forever. The realpath cache is the quieter win; 4 KB is far too small for the include paths OpenCart resolves per request.
We leave the PHP 8 JIT off: OpenCart request time is dominated by database round trips and file I/O, not tight numeric loops.
Redis: the two keys in OpenCart’s config
The default file cache writes a huge number of tiny files under system/storage/cache. On a busy store that becomes constant disk churn, and clearing it mid-campaign can take minutes. Both switches live in one file:
// system/config/default.php
$_['cache_engine'] = 'redis';
$_['cache_expire'] = 3600;
// sessions: 'file' (default) or 'db'
$_['session_engine'] = 'db';One caveat before flipping the switch: the Redis driver shipped with OpenCart, system/library/cache/redis.php, opens the connection with values written into the driver itself. If your Redis listens elsewhere or needs a password, read that file first — those details are not in default.php.
Sessions cost people the most time here. OpenCart 3 and 4 do not use PHP's native session handler; they have their own drivers, so pointing session.save_handler at Redis moves nothing — carts follow session_engine. Use db behind more than one application server, and make sure something deletes expired rows; nothing in the default install does.
# /etc/redis/redis.conf — cache-only instance
maxmemory 512mb
maxmemory-policy allkeys-lru
save ""
appendonly noallkeys-lru is the line that matters: a cache should evict its coldest entries when full, not refuse writes. That is safe precisely because this instance holds cache only — sessions are in the database. Disabling RDB snapshots removes the periodic fork pauses.
MySQL: the setting that actually matters
If you change one thing, change innodb_buffer_pool_size — the memory MySQL uses to hold data and index pages. The goal is to fit the working set. The query from the first section gives you the total: if that is 1.8 GB, a 2.5 GB pool holds all of it and the store stops going to disk to read a product.
[mysqld]
innodb_buffer_pool_size = 2560M
innodb_buffer_pool_instances = 2
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 1
innodb_file_per_table = 1
max_connections = 80
table_open_cache = 4000
tmp_table_size = 64M
max_heap_table_size = 64M
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1max_connections belongs just above pm.max_children, not ten times above it. Every connection reserves memory whether it is working or not, and a limit high enough for a runaway loop to open six hundred of them only means the server dies more thoroughly. On a store taking payments, keep innodb_flush_log_at_trx_commit at 1.
One last check for any store alive for years. Sites upgraded from OpenCart 1.5 or early 2.x often still carry MyISAM tables, which ignore the InnoDB buffer pool completely and take a full table lock on every write:
SELECT table_name, engine
FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine <> 'InnoDB';
-- then, in a maintenance window, per table:
ALTER TABLE oc_customer_online ENGINE=InnoDB;Before you call it done
A profile is finished when you have proven the store behaves under it, not when the config files are written:
- 1Clear the cache from the admin panel, then confirm keys appear in Redis with redis-cli dbsize.
- 2Place a real test order end to end on the live TLS hostname, including the return from the payment provider.
- 3Log into the admin from an allowed address, and check a blocked address is refused rather than handed config.php as text.
- 4Push a burst of traffic at a category page and watch the listen queue on /fpm-status stay at zero.
- 5Confirm the slow logs rotate, and that somebody reads error.log under system/storage.
- 6Reboot once. Nginx, PHP-FPM, Redis and MySQL should all come back on their own.
That last step catches more than the other five together. A server that cannot survive an unplanned reboot is not tuned, it is balanced.
When to write to us
If you would rather not try this on a live store on a Friday evening, we do it as a bounded piece of work: measure, propose the profile, apply it on a staging copy, then cut over. Billed hourly at the $10 + VAT launch rate, first response within two hours, Monday to Saturday 09:00–22:00 (GMT+3). This profile is also the standard build on our managed OpenCart hosting, so if you are changing servers anyway, the tuning comes with the move.
Docker, ArgoCD and internal developer platforms; edge computing.