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

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 cachenPerformance 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
| Mode | Behavior | Use Case |
|---|---|---|
| static | Fixed number of workers | Maximum performance, stable traffic |
| dynamic | Scales between min/max | Good compromise, variable traffic |
| ondemand | Workers only on demand | Minimal 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 = 0High-Performance Static Configuration
For dedicated servers with stable traffic:
pm = static
pm.max_children = 100 ; Alle Worker sofort verfügbar
pm.max_requests = 1000Advantage: 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=1Critical: validate_timestamps=0 means code changes only take effect after a PHP-FPM restart:
# Nach Deployment:
sudo systemctl restart php8.3-fpmEnabling 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ößeModes:
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 TabelleSizing guidelines:
| Server Type | Buffer Pool |
|---|---|
| Dedicated DB server | 80% RAM |
| Shared (Web + DB) | 50-70% RAM |
| Minimum per instance | 1 GB |
Checking Buffer Pool Efficiency
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';Key metrics:
Innodb_buffer_pool_read_requests: Read requests from bufferInnodb_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;
EOFAlternative: 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 loggenAnalysis:
# Slow Queries analysieren
mysqldumpslow -s t /var/log/mysql/slow.log | head -20
# Oder mit Percona Toolkit
pt-query-digest /var/log/mysql/slow.logConnection 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 Imports6. 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 DescriptorsApply:
sudo sysctl -pBBR 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 = bbrPrerequisite: 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 65536Method 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-fpmCheck:
# Aktuelle Limits
ulimit -n
# Service-spezifisch
systemctl show nginx.service | grep LimitNOFILE7. 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 4567890PHP-FPM Status
# Bereits in Pool-Config aktiviert: pm.status_path = /fpm-status
curl http://localhost/fpm-status?fullKey metrics:
active processes: Currently working workersidle processes: Waiting workerslisten 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
| Tool | Purpose |
|---|---|
| htop | CPU, RAM, Processes |
| iostat | Disk I/O |
| MySQLTuner | MySQL configuration check |
| Netdata | Real-time monitoring dashboard |
| Prometheus + Grafana | Enterprise monitoring |
# MySQLTuner
wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/mysqltuner.pl
perl mysqltuner.plComplete 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 = OFFConclusion: 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_sizeto 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.