LEMP Stack Architecture: Nginx, PHP-FPM, MariaDB, and Redis – Tuning for Production Load

Published: May 12, 2026 · Author: Marcin Szewczyk-Wilgan

This article is a comprehensive guide to tuning the LEMP stack (Linux, Nginx, MariaDB/MySQL, PHP-FPM) for production workloads. It covers the connection cascade between server layers, precise RAM budgeting for every component, OPcache and keepalive configuration, Redis and fastcgi_cache caching strategies, and professional bottleneck profiling with tools like strace, Performance Schema, and Flamegraph.

Introduction to modern LEMP architecture

Modern web applications – including high-end WordPress hosting – require an environment capable of serving content at speed and absorbing sudden traffic spikes. For years the industry standard was Apache with mod_php, which bound a PHP interpreter process directly to every HTTP request. Under production load that architecture quickly exhausts RAM, because even serving static images engages heavy, memory-hungry processes.

The LEMP stack (Linux, Nginx, MariaDB/MySQL, PHP-FPM) was the answer. In this model, Nginx – often acting as a reverse proxy – handles thousands of concurrent connections asynchronously, consuming a fraction of a megabyte of memory. PHP-FPM (FastCGI Process Manager) runs as a completely independent daemon, maintaining a pool of worker processes and communicating with the web server exclusively through Unix sockets or TCP ports. A MariaDB relational database and Redis in-memory data store complete the picture.

Optimizing WordPress or any other PHP application on LEMP is not about blindly throwing more hardware at the problem. The key to stability is precise, mathematically grounded tuning of each component's parameters so they mesh like perfectly fitted gears.

The feedback loop: the connection cascade in a LEMP stack

The most common configuration mistake is tuning each service in isolation. In reality, request flow creates a strict cascade – the parameters of each layer must correspond to the layers above and below it. The funnel works like this:

  1. Entry layer (Nginx): The worker_connections parameter – multiplied by the number of CPU cores, that is worker_processes – defines the maximum number of simultaneous TCP connections the server can handle. Production environments typically use values from 1024 to 8192.
  2. Logic layer (PHP-FPM): When Nginx receives a request that requires PHP execution, it passes it to the worker pool governed by pm.max_children. If Nginx accepts 2,000 requests but pm.max_children is only 50, the remaining 1,950 requests are immediately queued – which users experience as severe slowdowns, and which ultimately generates 502/504 Gateway Timeout errors.
  3. Data layer (MariaDB): Every active PHP worker can open a database connection. If pm.max_children is carelessly set to 300 while MariaDB's max_connections remains at its default of 151, a traffic spike will exhaust the database connection pool in seconds, producing fatal "Too many connections" errors at the application layer.

To prevent these failures, configuration must be deliberate: MariaDB's max_connections should always be somewhat higher – roughly 10–20% for administrative overhead – than the total pm.max_children limit across all active PHP pools. Nginx in turn must be able to hold client connections open long enough for PHP-FPM to service them.

Tuning Linux kernel limits (sysctl and ulimits)

A healthy connection cascade depends heavily on what the operating system itself permits. The default soft limit for open file descriptors on Linux is often as low as 1,024. An Nginx server handling heavy traffic – where every connection consumes file descriptors for network sockets and static files – will exhaust this limit quickly and start rejecting connections. The fix is to set the worker_rlimit_nofile directive to 65535 in the Nginx configuration, and to raise the system-wide limits in limits.conf or at the systemd service level with LimitNOFILE.

TCP/IP tuning also matters. Expand the local port range with net.ipv4.ip_local_port_range via sysctl, and increase the maximum network receive queue size with net.core.netdev_max_backlog to prevent packet drops during sudden traffic spikes.

A separate consideration is net.core.somaxconn, which controls the maximum length of the TCP backlog queue at the listen() system call level. On older Linux kernels (below 5.4) the default is only 128, which causes connections to be rejected before they ever reach Nginx under heavy load. Modern kernels (5.4+) raised the default to 4096, but in very high-traffic environments it is worth pushing this further – to 65535, for instance. This value must be synchronized with the backlog parameter in Nginx's listen directive: the lower of the two becomes the effective bottleneck.

Memory budgeting and configuration by available RAM

The primary constraint on every LEMP server is physical memory. The operating system, Nginx processes, MariaDB buffers, and PHP-FPM workers all need to fit within physical RAM to avoid swap, which kills performance the moment it is touched.

The core formula for a safe PHP worker count is:

pm.max_children = (Total RAM − OS reserve − Nginx − Redis − MariaDB buffers) / Average PHP process size

Measure the average process size on a running server by reading the RSS (Resident Set Size) of php-fpm processes. Depending on the plugin load, WordPress typically sits between 40 and 80 MB per worker.

The process manager mode (pm) in the PHP-FPM pool configuration is equally important. Three strategies are available. pm = static maintains a fixed, unchanging number of workers – predictable RAM consumption with no fork overhead, making it the recommended mode for servers dedicated to a single application. pm = dynamic scales the worker count between pm.min_spare_servers and pm.max_spare_servers, adapting to live traffic. pm = ondemand creates workers only when a request arrives and destroys them after an idle timeout (pm.process_idle_timeout) – it saves memory but adds latency on the first request after a quiet period. For production environments with predictable traffic, static delivers the lowest latency.

Here are safe memory distributions for three common server sizes:

1. Entry-level server: 4 GB RAM (e.g. 2 vCPUs)

At this scale, aggressive memory conservation is essential. MariaDB cannot allocate large buffers, and concurrent connections must be strictly capped.

  • MariaDB: Set innodb_buffer_pool_size to approximately 1.5 GB – just over a third of available RAM.
  • Redis: Allocate a modest 256 MB via maxmemory.
  • PHP-FPM: Assuming an average worker size of 50 MB, roughly 1.5 GB remains for PHP. A safe value for pm.max_children is 30.
  • Nginx: Set worker_connections to 2048, backed by strong micro-cache support.
  • MariaDB: Cap max_connections at 60.

2. Optimal server: 16 GB RAM (e.g. 4 to 8 vCPUs)

This is the sweet spot for serious portals and e-commerce sites. There is enough memory to keep a large portion of the database in RAM and to service substantial live traffic.

  • MariaDB: Set innodb_buffer_pool_size to 8 GB – half of available RAM. The database will operate remarkably fast, largely bypassing disk I/O.
  • Redis: Expand the limit to 1 GB.
  • PHP-FPM: Approximately 5 GB remains for application logic. Set pm.max_children to 100.
  • Nginx: Set worker_connections to 4096 or 8192.
  • MariaDB: Set max_connections to 150 – comfortable headroom above 100 PHP workers, plus margin for other services.

3. Enterprise server: 32 GB RAM (e.g. 8 to 16 vCPUs)

An environment for large e-commerce operations and high-traffic news portals. At this scale, carefully choose the initial PHP-FPM pool size (pm.start_servers) to avoid overloading the CPU during a mass fork of new workers.

  • MariaDB: innodb_buffer_pool_size can be allocated as much as 20 GB.
  • Redis: Gets a comfortable 2 GB.
  • PHP-FPM: Approximately 8 GB remains for the application tier, supporting a pm.max_children value of 150 to 200 depending on script weight.
  • Nginx: worker_connections can be raised to 16384.
  • MariaDB: max_connections at 300 is safe and appropriate.

Advanced PHP OPcache tuning and memory leak prevention

The interpreter's own execution management has an enormous effect on application throughput. OPcache stores pre-compiled PHP bytecode directly in RAM, which can improve script execution speed by a factor of two to three. The prerequisite is correct configuration – particularly, sufficiently high values for opcache.memory_consumption and opcache.max_accelerated_files.

Memory leak control is equally critical. The PHP-FPM pool configuration must define pm.max_requests = 500. This causes each worker to automatically and safely restart after handling exactly 500 requests. The mechanism prevents poorly written application code or bloated plugins from gradually consuming all available RAM.

In production environments, an additional optimization is to set opcache.validate_timestamps = 0. By default, OPcache periodically checks (according to opcache.revalidate_freq) whether PHP files on disk have changed. Disabling this validation eliminates unnecessary stat() calls on every request – OPcache serves only the compiled version from memory. The trade-off is that the cache must be invalidated manually after each deployment, either by restarting PHP-FPM or by calling opcache_reset() in a deploy script.

Optimizing keepalive connections between Nginx and PHP-FPM

Another bottleneck worth eliminating is the communication method between the web server and the interpreter. When both run on the same physical server, Unix sockets are always faster than TCP ports. However, by default Nginx opens and closes a new FastCGI connection on every request. In the upstream block of the Nginx configuration, persistent connections should be enabled with the keepalive 32 directive. This allows the system to intelligently reuse already-open sockets, dramatically reducing the CPU overhead incurred by repeated connection negotiation and handshake cycles.

Tuning MariaDB for SSD/NVMe storage and thread concurrency

Database memory allocation goes beyond sizing the InnoDB buffer pool. The max_connections parameter does not pre-allocate memory at startup, but it does define the peak memory footprint of the database. Each new connection creates a dedicated thread that requires immediate memory allocation. The most significant component of that allocation is the thread stack, controlled by the thread_stack parameter.

When tuning for fast SSD storage, set innodb_flush_neighbors = 0. This immediately disables unnecessary write coalescing of adjacent blocks – a feature that was useful in the past but only ever made sense for optimizing physical head movement on spinning disks.

Aggressive real-time buffer flushing also pays dividends. Raise innodb_io_capacity – for example to 10000 – and innodb_io_capacity_max – to 20000 or higher – calibrated to the actual performance characteristics of the installed SSD or NVMe drives. This allows the engine to flush dirty pages from memory to disk far more quickly and smoothly.

A frequently overlooked parameter is the redo log file size. In MariaDB up to version 10.5, this is controlled by innodb_log_file_size (defaulting to 48 MB) in combination with innodb_log_files_in_group. An undersized log forces frequent checkpoint operations – the engine must pause writes to free space in the log – which severely limits write throughput under load. For production servers, values in the range of 256 MB to 1 GB are recommended. In MariaDB 10.6 and later these parameters were replaced by a single innodb_redo_log_capacity, which defines the total size of the entire redo log space.

Caching strategies: Redis versus fastcgi_cache

Without an intelligent caching strategy, even the most powerful server will eventually buckle under a flood of PHP and SQL requests. This is where Nginx and Redis enter – cutting off the request path at different points in the funnel.

Redis as an object cache

Loading a complex page can trigger anywhere from dozens to several hundred SQL queries against MariaDB. Using Redis as an object store means the results of those expensive queries are held as key-value pairs in RAM. When the next PHP worker needs the same data, it pulls it from Redis at sub-millisecond latency, bypassing the InnoDB engine entirely.

Keeping an object cache stable under load requires careful overflow management. In redis.conf, the maxmemory limit must be set firmly – hard-capped to a budget that is safe given the total RAM on the host.

Pair that with maxmemory-policy allkeys-lru. With this setting, Redis automatically evicts the least recently used objects once the memory limit is reached, instead of returning OOM errors on new writes.

When Redis serves exclusively as a cache – object or full-page – disk persistence should be disabled. By default Redis periodically creates RDB snapshots, which require a fork() that copies the entire process address space. On a server where Redis holds several gigabytes, that fork generates noticeable, periodic slowdowns. Setting save "" and appendonly no in redis.conf eliminates this overhead. Cache data is inherently regenerable – if the service restarts, the cache simply fills up again.

Nginx fastcgi_cache vs Redis Full-Page Cache

Both fastcgi_cache and Redis Full-Page Cache store the fully rendered HTML of a page. When a cached request arrives, Nginx (or a Redis-integrated script) returns the finished HTML directly, cutting the PHP interpreter out of the loop entirely.

How to choose between them:

  1. Nginx fastcgi_cache: Operates at the web server layer, storing rendered output in highly efficient shared memory regions (or on a fast local disk). It is nearly unmatched for performance – the request never leaves Nginx's C layer. Ideal for brochure sites, blogs, and portals where content looks identical to all non-logged-in users.
  2. Redis Full-Page Cache: Accomplishes the same task but stores HTML in the Redis infrastructure. Its major advantage is horizontal scalability: if the LEMP stack spans multiple Nginx instances, all of them can query a single centralized Redis cluster. For a single-server reverse proxy setup, however, the built-in fastcgi_cache carries slightly lower operational overhead.

Bottleneck profiling: identifying performance problems

Even a correctly tuned server can slow down over time due to bad application code – poorly written plugins, for example. Professional stack administration requires familiarity with the diagnostic tools that let you pinpoint the slowing component precisely.

MariaDB Slow Query Log

The first step in database relief is identifying unoptimized queries. With Slow Query Log enabled in MariaDB, enable extended statistics with log_slow_verbosity. Setting the value to query_plan,explain causes the database to write not just the query text but also information about whether a full table scan occurred instead of an index lookup – an immediate structural optimization lead.

When that is not enough, the integrated Performance Schema engine provides a deeper view. Through tables such as events_waits_current, an administrator can inspect in real time exactly which wait event each database thread is blocked on – whether it is waiting for a physical disk I/O read, or stuck behind a mutex lock.

Diagnosing PHP-FPM slowdowns: slowlog and strace

When PHP-FPM saturates – the error log fills with "server reached pm.max_children setting" – an undersized process pool is almost never the real cause. A slow external operation is almost always to blame.

Setting request_slowlog_timeout in the FPM configuration (for example to 5s) and directing it to a log file forces the process to dump a stack trace whenever a script exceeds five seconds. Often this is enough to locate the offending code.

If the problem does not originate in the PHP script itself – say, it is slow DNS resolution or a lagging external API call – PHP's slowlog will not help. In such cases engineers reach for the system-level strace tool. Attaching to a blocked php-fpm process with strace -p reveals all kernel system calls in real time, immediately exposing a process spinning uselessly in poll() or connect() waiting for a remote server.

CPU profiling: perf and Flamegraph

When the environment shows high CPU load with no obvious database bottleneck, the perf tool is the right instrument. Running perf record collects call stack samples from all php and mariadb subprocesses. The resulting data, visualized as Flame Graphs, makes it immediately clear which C function – inside the Linux kernel, a PHP extension, or the database engine – is burning the most CPU cycles on the server.

Conclusion

Building and optimizing a stack based on Nginx, PHP-FPM, MariaDB, and Redis is a process that extends well beyond installing the services. It demands tight harmonization between web server parameters, worker limits, and database constraints. Correctly tuning worker_connections, pm.max_children, and InnoDB buffer limits in proportion to available RAM is critical for achieving stability. Rounding the stack out with a built-in reverse proxy cache (fastcgi_cache) and a Redis object cache, supported by professional monitoring tools (strace, Performance Schema), guarantees that the environment can sustain the most demanding production traffic without interruption.

At WebOptimo we specialize in building and optimizing high-performance server environments for WordPress and WooCommerce. If you are planning a LEMP stack deployment or a performance audit in a production environment, get in touch. You can also explore our services for server administration, Linux administration, WordPress hosting, WordPress optimization, and MySQL administration.

FAQ – The LEMP stack

In a LAMP stack, Apache handles HTTP requests while simultaneously running the PHP interpreter through mod_php, engaging heavyweight processes even for static file delivery. In LEMP, Nginx handles connections asynchronously and PHP-FPM operates as an independent process manager communicating with the web server over Unix sockets. As a result, LEMP consumes significantly less RAM under high traffic.

The formula is: pm.max_children = (Total RAM − OS reserve − Nginx − Redis − MariaDB buffers) / Average PHP process size. Measure the average process size on a running server by reading the RSS (Resident Set Size) of php-fpm processes. For WordPress it typically falls between 40 and 80 MB.

Every PHP-FPM worker can open a database connection. If pm.max_children is 100 and MariaDB's max_connections is the default 151, a sudden traffic spike will exhaust the connection pool. The max_connections value should be 10–20% higher than the total pm.max_children across all active PHP pools.

Nginx fastcgi_cache is optimal for a single server – the request never leaves Nginx's C layer, delivering the lowest possible latency. Redis Full-Page Cache is better for horizontal scalability, when multiple Nginx instances all query a single centralized Redis cluster.

Almost always the problem is not an undersized process pool but a slow external operation. Enable request_slowlog_timeout in the FPM configuration to identify slow scripts. If the bottleneck lies outside PHP – slow DNS or a lagging API – attach strace to the blocked php-fpm process to see exactly what system call it is waiting on.

No. On a single LEMP server Redis runs locally with maxmemory sized to the available RAM budget. The critical setting is maxmemory-policy allkeys-lru, which causes Redis to automatically evict the least recently used keys rather than returning OOM errors on new writes.

MariaDB's Slow Query Log (with log_slow_verbosity) identifies slow SQL queries. Performance Schema surfaces thread-level wait events inside the database. PHP-FPM's slowlog and strace locate slow scripts and blocking system calls. The perf tool combined with Flamegraph visualizes CPU load at the level of C functions in the kernel, PHP extensions, and the database engine.

Let's talk about your infrastructure

LEMP stack tuning, performance audit, WordPress optimization. No commitment – a concrete proposal after analysis.

Phone

+48 608 271 665

Mon–Fri, 8:00–16:00 CET

E-mail

kontakt@weboptimo.pl

We respond within 24 hours

Company

WebOptimo

VAT ID: PL6391758393