LEMP Stack Tuning: Optimally Configuring Nginx, PHP-FPM, and MySQL

LEMP Stack Tuning: Nginx, PHP-FPM und MySQL optimal konfigurieren

Photo by Steve Johnson on Unsplash

A misconfigured LEMP stack wastes 50-70% of its potential performance. In this article, I'll show you the most important tuning parameters for Nginx, PHP-FPM, and MySQL -- with concrete configuration examples for production environments.

Why LEMP Instead of LAMP?

The LEMP stack (Linux, Nginx, MySQL/MariaDB, PHP) outperforms the classic LAMP stack (with Apache) under high concurrency:

  • Nginx is event-based and asynchronous
  • Apache creates one process per connection

With thousands of simultaneous connections, this makes an enormous difference in memory consumption and response time.

What you can achieve after this article:

  • 2-4x more concurrent connections with the same RAM
  • 30-50% faster response times through FastCGI Cache
  • 50% CPU reduction through OPcache and JIT

1. Nginx Optimization

Worker Processes and Connections

The most important Nginx settings are in /etc/nginx/nginx.conf:

user www-data;
worker_processes auto;                    # Automatisch: 1 Worker pro CPU-Kern
worker_rlimit_nofile 65536;              # Max offene Dateien pro Worker
pid /run/nginx.pid;
 
events {
    use epoll;                            # Effizientes Event-Polling (Linux)
    worker_connections 4096;              # Verbindungen pro Worker
    multi_accept on;                      # Alle neuen Verbindungen auf einmal akzeptieren
}

Capacity calculation:

Maximale Verbindungen = worker_processes × worker_connections
Beispiel: 4 CPU-Kerne × 4096 = 16.384 gleichzeitige Verbindungen

Buffer Settings

Buffers that are too small force Nginx to write temporary files to disk:

http {
    # Client Buffer
    client_body_buffer_size 10K;
    client_header_buffer_size 1k;
    client_max_body_size 8m;              # Max Upload-Größe
    large_client_header_buffers 4 4k;
 
    # Timeouts
    client_body_timeout 12;
    client_header_timeout 12;
    keepalive_timeout 15;
    send_timeout 10;
 
    # TCP-Optimierungen
    sendfile on;                          # Zero-Copy für statische Dateien
    tcp_nopush on;                        # Header in einem Paket senden
    tcp_nodelay on;                       # Keine Verzögerung bei kleinen Paketen
}

Gzip Compression

Gzip reduces bandwidth by 30-50% for text-based content:

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;                        # Kompression 1-9 (6 = guter Kompromiss)
gzip_types text/plain text/css text/xml text/javascript
           application/json application/javascript
           application/xml+rss application/rss+xml
           font/truetype font/opentype
           image/svg+xml;
gzip_disable "msie6";

Important: Do not compress already-compressed formats (JPEG, PNG, videos).

Enabling HTTP/2 and HTTP/3

HTTP/2 is standard, HTTP/3 (QUIC) brings advantages for mobile connections:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
 
    # HTTP/3 (Nginx 1.25+, erfordert OpenSSL 3.5.1+)
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
 
    http2 on;
    http3 on;
    quic_retry on;
    ssl_early_data on;                    # 0-RTT für schnellere Verbindungen
 
    # HTTP/3 Support signalisieren
    add_header Alt-Svc 'h3=":443"; ma=86400';
 
    # TLS 1.3 ist Voraussetzung für QUIC
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_certificate /etc/ssl/certs/domain.crt;
    ssl_certificate_key /etc/ssl/private/domain.key;
}

Prerequisites for HTTP/3:

  • Nginx 1.25.0 or newer
  • OpenSSL 3.5.1+ (or BoringSSL/QuicTLS)
  • Linux Kernel 5.7+ for optimal performance
  • Firewall: UDP port 443 must be open!

Security Headers

Security and performance are not mutually exclusive:

# HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
 
# Clickjacking-Schutz
add_header X-Frame-Options "SAMEORIGIN" always;
 
# MIME-Sniffing verhindern
add_header X-Content-Type-Options "nosniff" always;
 
# Referrer-Policy
add_header Referrer-Policy "no-referrer-when-downgrade" always;
 
# Content Security Policy (erst testen, dann enforcing!)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;

2. Configuring FastCGI Cache

The FastCGI Cache is the biggest performance lever for PHP applications. It stores fully rendered pages and bypasses PHP entirely on cache hits.

Setting Up the Cache Path

In /etc/nginx/nginx.conf within the http block:

http {
    # Cache-Verzeichnis definieren
    fastcgi_cache_path /var/cache/nginx
        levels=1:2
        keys_zone=PHPCACHE:100m           # 100 MB für Cache-Keys
        max_size=10g                       # Max 10 GB Cache-Daten
        inactive=60m                       # Ungenutzte Einträge nach 60 Min löschen
        use_temp_path=off;
 
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
}

Activating Cache in the Server Block

server {
    # Cache-Bypass Variablen
    set $skip_cache 0;
 
    # POST-Requests nicht cachen
    if ($request_method = POST) {
        set $skip_cache 1;
    }
 
    # Query-Strings nicht cachen
    if ($query_string != "") {
        set $skip_cache 1;
    }
 
    # Admin-Bereiche und Logins ausschließen
    if ($request_uri ~* "/wp-admin/|/wp-login.php|/admin/|/login") {
        set $skip_cache 1;
    }
 
    # Eingeloggte Benutzer nicht cachen
    if ($http_cookie ~* "wordpress_logged_in|PHPSESSID|laravel_session") {
        set $skip_cache 1;
    }
 
    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
 
        # Cache-Konfiguration
        fastcgi_cache PHPCACHE;
        fastcgi_cache_valid 200 301 302 60m;
        fastcgi_cache_valid 404 1m;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;
        fastcgi_cache_lock on;            # Verhindert "Cache Stampede"
        fastcgi_cache_use_stale error timeout updating;
 
        # Debug-Header (in Produktion entfernen)
        add_header X-Cache-Status $upstream_cache_status;
    }
}

Microcaching for High Traffic

For high-traffic pages, you can cache even dynamic content for 1 second:

fastcgi_cache_valid 200 1s;               # Nur 1 Sekunde cachen

Performance gain: Up to 400x faster during traffic spikes, as identical requests within one second are served from cache.


3. PHP-FPM Tuning

PHP-FPM is the "Fast Process Manager" for PHP and critical for performance.

Understanding Process Manager Modes

ModeBehaviorUse Case
staticFixed number of workersMaximum performance, stable traffic
dynamicScales between min/maxGood compromise, variable traffic
ondemandWorkers only on demandMinimal RAM, higher latency

Recommendation: dynamic for most production environments.

Calculating pm.max_children

The most important setting is pm.max_children. Too many workers lead to memory bottlenecks, too few to queuing.

Formula:

pm.max_children = (Verfügbarer RAM - OS-Overhead) / RAM pro PHP-Worker

Beispiel (8 GB RAM Server):
- OS und Services: ~2 GB
- Verfügbar für PHP: 6 GB
- PHP-Worker (WordPress): ~128 MB
- max_children = 6000 MB / 128 MB ≈ 46

Complete Pool Configuration

In /etc/php/8.3/fpm/pool.d/www.conf:

[www]
user = www-data
group = www-data
 
; Socket statt TCP (schneller)
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
listen.backlog = 65535
 
; Process Manager
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500                     ; Worker recyceln (Memory Leaks verhindern)
pm.process_idle_timeout = 10s
 
; Monitoring (Status-Seite)
pm.status_path = /fpm-status
ping.path = /fpm-ping
ping.response = pong
 
; Slow Log (Requests > 5 Sekunden)
slowlog = /var/log/php8.3-fpm-slow.log
request_slowlog_timeout = 5s
 
; PHP-Einstellungen pro Pool
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 30
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
 
; Resource Limits
rlimit_files = 8192
rlimit_core = 0

High-Performance Static Configuration

For dedicated servers with stable traffic:

pm = static
pm.max_children = 100                     ; Alle Worker sofort verfügbar
pm.max_requests = 1000

Advantage: No overhead for process management. Disadvantage: Consistently high RAM usage.


4. OPcache and JIT (PHP 8.3)

OPcache stores compiled PHP bytecode in RAM and eliminates repeated parsing.

OPcache Configuration

In /etc/php/8.3/fpm/conf.d/10-opcache.ini:

[opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256            ; 256-384 MB für Produktionsserver
opcache.interned_strings_buffer=64        ; String-Interning (wichtig für Frameworks)
opcache.max_accelerated_files=50000       ; Max gecachte Dateien
opcache.validate_timestamps=0             ; WICHTIG: In Produktion deaktivieren!
opcache.revalidate_freq=0
opcache.save_comments=1                   ; Für Doctrine/Annotations nötig
opcache.huge_code_pages=1                 ; Huge Pages nutzen (Linux)
opcache.enable_file_override=1

Critical: validate_timestamps=0 means code changes only take effect after a PHP-FPM restart:

# Nach Deployment:
sudo systemctl restart php8.3-fpm

Enabling JIT (PHP 8.0+)

The JIT compiler provides additional performance for CPU-intensive operations:

opcache.jit=tracing                       ; Empfohlen: tracing (oder function)
opcache.jit_buffer_size=100M              ; JIT-Buffer Größe

Modes:

  • tracing: Analyzes the entire call stack (recommended)
  • function: Optimizes only within individual functions

Limitation: JIT does not work with Xdebug enabled.

Monitoring OPcache Status

<?php
// opcache-status.php
$status = opcache_get_status();
$config = opcache_get_configuration();
 
$hitRate = $status['opcache_statistics']['hits'] /
           ($status['opcache_statistics']['hits'] + $status['opcache_statistics']['misses']) * 100;
 
echo "Hit Rate: " . round($hitRate, 2) . "%\n";
echo "Memory Used: " . round($status['memory_usage']['used_memory'] / 1024 / 1024, 2) . " MB\n";
echo "Cached Scripts: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";

Target: Hit Rate > 98%, no OOM restarts.


5. MySQL/MariaDB Performance

Sizing the InnoDB Buffer Pool

The Buffer Pool is the most important MySQL parameter. It caches data and indexes in RAM.

[mysqld]
# Buffer Pool: 60-80% des RAM auf dedizierten DB-Servern
innodb_buffer_pool_size = 8G
innodb_buffer_pool_instances = 8          ; Bei > 1 GB Buffer Pool
innodb_buffer_pool_chunk_size = 128M
 
# Log-Dateien
innodb_log_file_size = 2G                 ; ~25% des Buffer Pools
innodb_log_buffer_size = 64M
 
# I/O Optimierung
innodb_flush_method = O_DIRECT            ; Kein Double-Buffering (Linux)
innodb_flush_log_at_trx_commit = 2        ; 1=ACID, 2=Performance
innodb_flush_neighbors = 0                ; Für SSDs
 
# Datei-Organisation
innodb_file_per_table = 1                 ; Separate Datei pro Tabelle

Sizing guidelines:

Server TypeBuffer Pool
Dedicated DB server80% RAM
Shared (Web + DB)50-70% RAM
Minimum per instance1 GB

Checking Buffer Pool Efficiency

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';

Key metrics:

  • Innodb_buffer_pool_read_requests: Read requests from buffer
  • Innodb_buffer_pool_reads: Read requests from disk

Calculating hit rate:

Hit Rate = (1 - reads / read_requests) × 100%
Ziel: > 99%

Query Cache Alternative: ProxySQL

The Query Cache was removed in MySQL 8.0. ProxySQL offers a modern replacement:

# ProxySQL installieren
sudo apt install proxysql
 
# Query Cache aktivieren
mysql -h 127.0.0.1 -P 6032 -u admin -padmin <<EOF
UPDATE global_variables SET variable_value='256' WHERE variable_name='mysql-query_cache_size_MB';
UPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-query_cache_stores_empty_result';
LOAD MYSQL VARIABLES TO RUNTIME;
SAVE MYSQL VARIABLES TO DISK;
EOF

Alternative: Use Redis or Memcached at the application level.

Slow Query Log

Slow queries are the most common performance killer:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1                       ; Queries > 1 Sekunde loggen
log_queries_not_using_indexes = 1         ; Auch ohne Index loggen

Analysis:

# Slow Queries analysieren
mysqldumpslow -s t /var/log/mysql/slow.log | head -20
 
# Oder mit Percona Toolkit
pt-query-digest /var/log/mysql/slow.log

Connection Management

max_connections = 500                     ; An Traffic anpassen
wait_timeout = 600                        ; Idle-Timeout (10 Min)
interactive_timeout = 600
max_allowed_packet = 64M                  ; Für große Imports

6. Linux Kernel Tuning

The operating system is often the overlooked bottleneck.

TCP/IP Optimizations

In /etc/sysctl.conf:

# TCP Performance
net.ipv4.tcp_syncookies = 1               # SYN-Flood Schutz
net.ipv4.tcp_max_syn_backlog = 65535      # Backlog-Queue
net.core.somaxconn = 65535                # Max wartende Verbindungen
net.ipv4.tcp_fin_timeout = 30             # TIME_WAIT Timeout reduzieren
 
# TCP Buffer (für Hochgeschwindigkeitsnetze)
net.core.rmem_max = 16777216              # Max Empfangsbuffer (16 MB)
net.core.wmem_max = 16777216              # Max Sendebuffer (16 MB)
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 87380 16777216
 
# BBR Congestion Control (Linux 4.9+)
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
 
# Memory Management
vm.swappiness = 1                         # Swapping minimieren (0-100)
vm.vfs_cache_pressure = 50                # Filesystem Cache behalten
vm.overcommit_memory = 1                  # Memory Overcommit erlauben
vm.dirty_ratio = 10                       # Dirty Page Threshold
vm.dirty_background_ratio = 5
 
# File System
fs.file-max = 400000                      # System-weite File Descriptors

Apply:

sudo sysctl -p

BBR Congestion Control

BBR (Bottleneck Bandwidth and Round-trip propagation time) from Google improves throughput:

# BBR aktivieren
echo "net.core.default_qdisc = fq" | sudo tee -a /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control = bbr" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
 
# Prüfen
sysctl net.ipv4.tcp_congestion_control
# Ausgabe: net.ipv4.tcp_congestion_control = bbr

Prerequisite: Linux Kernel 4.9 or newer.

File Descriptor Limits

Default limits are often too low for web servers:

Method 1: /etc/security/limits.conf

www-data soft nofile 65536
www-data hard nofile 65536
nginx soft nofile 65536
nginx hard nofile 65536
mysql soft nofile 65536
mysql hard nofile 65536

Method 2: systemd Override

# Für Nginx
sudo mkdir -p /etc/systemd/system/nginx.service.d/
sudo cat > /etc/systemd/system/nginx.service.d/limits.conf <<EOF
[Service]
LimitNOFILE=65536
EOF
 
# Für PHP-FPM
sudo mkdir -p /etc/systemd/system/php8.3-fpm.service.d/
sudo cat > /etc/systemd/system/php8.3-fpm.service.d/limits.conf <<EOF
[Service]
LimitNOFILE=65536
EOF
 
# Reload
sudo systemctl daemon-reload
sudo systemctl restart nginx php8.3-fpm

Check:

# Aktuelle Limits
ulimit -n
 
# Service-spezifisch
systemctl show nginx.service | grep LimitNOFILE

7. Monitoring and Validation

Nginx Status

# In server-Block hinzufügen
location /nginx_status {
    stub_status on;
    allow 127.0.0.1;
    deny all;
}
curl http://localhost/nginx_status
# Active connections: 42
# server accepts handled requests
#  1234567 1234567 4567890

PHP-FPM Status

# Bereits in Pool-Config aktiviert: pm.status_path = /fpm-status
curl http://localhost/fpm-status?full

Key metrics:

  • active processes: Currently working workers
  • idle processes: Waiting workers
  • listen queue: Queue (should be 0!)
  • max children reached: Limit reached (problematic!)

MySQL Performance

-- InnoDB Status
SHOW ENGINE INNODB STATUS\G
 
-- Key Performance Metrics
SHOW GLOBAL STATUS WHERE Variable_name LIKE 'Innodb_buffer_pool%'
   OR Variable_name LIKE 'Threads_%'
   OR Variable_name LIKE 'Slow_queries';

Recommended Tools

ToolPurpose
htopCPU, RAM, Processes
iostatDisk I/O
MySQLTunerMySQL configuration check
NetdataReal-time monitoring dashboard
Prometheus + GrafanaEnterprise monitoring
# MySQLTuner
wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/mysqltuner.pl
perl mysqltuner.pl

Complete Example Configurations

nginx.conf

user www-data;
worker_processes auto;
worker_rlimit_nofile 65536;
pid /run/nginx.pid;
 
events {
    use epoll;
    worker_connections 4096;
    multi_accept on;
}
 
http {
    # Basics
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 15;
    keepalive_requests 100;
    types_hash_max_size 2048;
    server_tokens off;
 
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
 
    # Buffer
    client_body_buffer_size 10K;
    client_header_buffer_size 1k;
    client_max_body_size 64m;
    large_client_header_buffers 4 4k;
 
    # Timeouts
    client_body_timeout 12;
    client_header_timeout 12;
    send_timeout 10;
 
    # Gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml text/javascript
               application/json application/javascript application/xml+rss;
 
    # FastCGI Cache
    fastcgi_cache_path /var/cache/nginx levels=1:2
        keys_zone=PHPCACHE:100m max_size=10g inactive=60m use_temp_path=off;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
 
    # File Cache
    open_file_cache max=1000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
 
    # Logging
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
 
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

my.cnf (MySQL/MariaDB)

[mysqld]
user = mysql
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
port = 3306
datadir = /var/lib/mysql
 
# Character Set
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
 
# InnoDB
innodb_buffer_pool_size = 4G
innodb_buffer_pool_instances = 4
innodb_log_file_size = 1G
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
innodb_file_per_table = 1
innodb_flush_neighbors = 0
 
# Connections
max_connections = 300
wait_timeout = 600
interactive_timeout = 600
max_allowed_packet = 64M
 
# Logging
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
 
# Performance Schema (deaktivieren für weniger Overhead)
performance_schema = OFF

Conclusion: Prioritized Checklist

1. Critical (Implement Immediately)

  • Set Nginx worker_processes auto
  • Calculate and configure PHP-FPM pm.max_children
  • Enable OPcache with validate_timestamps=0
  • Set MySQL innodb_buffer_pool_size to 50-80% RAM
  • Increase file descriptor limits to 65536

2. Important (High Impact)

  • Enable FastCGI Cache
  • Enable Gzip compression
  • Enable HTTP/2
  • Enable TCP BBR Congestion Control
  • Set security headers

3. Recommended (Additional Gains)

  • Enable HTTP/3 (if supported)
  • Enable PHP JIT
  • Evaluate ProxySQL for query caching
  • Set up monitoring (Nginx Status, FPM Status, MySQLTuner)

Expected improvements:

  • Response time: 30-50% faster
  • Concurrent connections: 2-4x more
  • CPU utilization: 30-50% reduction

Have questions about configuration? Contact me -- I'm happy to help you optimize your LEMP stack.