> Production-grade guide to building, securing, deploying, and monitoring Linux web stacks — Nginx/Apache, PHP-FPM/module, MariaDB/MySQL.
graph TB
subgraph LEMP["LEMP (Nginx)"]
C1[Client] --> N[Nginx]
N -->|FastCGI| FPM[PHP-FPM]
FPM --> DB1[(MariaDB)]
N -->|Static| FS1[/var/www/]
end
subgraph LAMP["LAMP (Apache)"]
C2[Client] --> A[Apache MPM]
A -->|mod_php/FPM| PHP2[PHP]
PHP2 --> DB2[(MariaDB)]
A -->|.htaccess| HT[Per-Dir Config]
end
style LEMP fill:#e8f5e9,stroke:#2e7d32
style LAMP fill:#e3f2fd,stroke:#1565c0
sequenceDiagram
C->>N: GET /app.php
N->>N: Location match
alt Static Asset
N-->>C: 200 + file
else PHP Script
N->>FPM: FastCGI :9000
FPM->>DB: Query
DB-->>FPM: Result
FPM-->>N: Response
N-->>C: 200 + gzip
end
Choose LEMP for high concurrency, low RAM, reverse proxy needs. Choose LAMP for .htaccess, mod_php simplicity, shared hosting.
# /etc/nginx/nginx.conf
user www-data; worker_processes auto; worker_rlimit_nofile 65535;
events { worker_connections 4096; multi_accept on; use epoll; }
http {
sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; server_tokens off;
gzip on; gzip_vary on; gzip_comp_level 4; gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=3r/m;
limit_conn_zone $binary_remote_addr zone=connlimit:10m;
upstream php-fpm { server unix:/run/php/php8.2-fpm.sock; keepalive 32; }
include /etc/nginx/sites-enabled/*;
}
server { listen 80; server_name example.com; return 301 https://example.com$request_uri; }
server {
listen 443 ssl http2; server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always;
root /var/www/example.com/public;
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2)$ { expires 30d; try_files $uri =404; }
location ~ \.php$ {
limit_req zone=general burst=20 nodelay; limit_conn connlimit 25;
fastcgi_pass php-fpm; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_keep_conn on; fastcgi_buffer_size 128k; fastcgi_buffers 4 256k;
fastcgi_read_timeout 120s;
}
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~* /(?:wp-config\.php|\.env)$ { deny all; }
location ~ /\. { deny all; }
location = /health { access_log off; return 200 'OK'; }
}
upstream api { least_conn; server 10.0.1.10:8080; server 10.0.1.11:8080; keepalive 16; }
server {
listen 443 ssl http2; server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / { proxy_pass http://api; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; }
location /ws { proxy_pass http://api; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s; }
}
# /etc/php/8.2/fpm/pool.d/www.conf
[www]
user = www-data; group = www-data
listen = /run/php/php8.2-fpm.sock; listen.mode = 0660; listen.backlog = 65535
pm = dynamic
pm.max_children = 50; pm.start_servers = 5; pm.min_spare_servers = 3; pm.max_spare_servers = 10
pm.max_requests = 1000; pm.process_idle_timeout = 10s
pm.status_path = /fpm-status
slowlog = /var/log/php-fpm/www-slow.log; request_slowlog_timeout = 5s
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_value[expose_php] = Off; php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 20M
php_admin_value[session.save_handler] = redis
php_admin_value[session.save_path] = "tcp://127.0.0.1:6379"
Pool sizing: pm.max_children = (Available_RAM × 0.8) / avg_process_size. Check size: ps -ylC php-fpm --sort:rss.
# /etc/mysql/mariadb.conf.d/50-server.cnf
[mysqld]
default_storage_engine = InnoDB
innodb_buffer_pool_size = 2G; innodb_buffer_pool_instances = 4
innodb_log_file_size = 512M; innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 2; innodb_file_per_table = 1
max_connections = 200; wait_timeout = 600
log_bin = /var/log/mysql/mysql-bin; binlog_format = ROW; expire_logs_days = 7
slow_query_log = 1; long_query_time = 2; log_queries_not_using_indexes = 1
character_set_server = utf8mb4; collation_server = utf8mb4_unicode_ci
# MPM Event config
StartServers 3; MinSpareThreads 25; MaxSpareThreads 75
ThreadsPerChild 25; MaxRequestWorkers 150; MaxConnectionsPerChild 3000
KeepAlive On; KeepAliveTimeout 5
ServerName example.com; DocumentRoot /var/www/example.com/public
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/cert.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
Header always set Strict-Transport-Security "max-age=15768000"
Options -Indexes +FollowSymLinks; AllowOverride All; Require all granted
SetHandler "proxy:unix:/run/php/php8.2-fpm.sock|fcgi://localhost"
RewriteEngine On
RewriteCond %{HTTPS} off; RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
RewriteCond %{REQUEST_FILENAME} !-f; RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L,QSA]
Require all denied
ExpiresActive On; ExpiresByType text/css "access plus 30 days"
ExpiresByType image/jpeg "access plus 1 year"; ExpiresByType font/woff2 "access plus 1 year"
AddOutputFilterByType DEFLATE text/html text/css application/javascript
| Metric | mod_php (prefork) | PHP-FPM (event) |
|--------|-------------------|------------------|
| RAM/process | ~50–80 MB | ~8–20 MB |
| Max concurrent | ~200 | ~1000+ |
| Hot restart | No | Yes |
| .htaccess php_value | ✓ | ✗ (.user.ini) |
| Security isolation | Shared process | Separate UID |
# UFW
ufw --force reset; ufw default deny incoming; ufw default allow outgoing
ufw limit 22/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable
SSH (/etc/ssh/sshd_config)
Port 2222; PermitRootLogin no; PasswordAuthentication no; MaxAuthTries 3
AllowUsers deploy admin; KexAlgorithms curve25519-sha256
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
# /etc/fail2ban/jail.local
[DEFAULT] bantime = 3600; findtime = 600; maxretry = 5
[sshd] enabled = true; port = ssh; maxretry = 3; bantime = 7200
[nginx-botsearch] enabled = true; port = http,https; maxretry = 10
[recidive] enabled = true; bantime = 86400; maxretry = 3
# Let's Encrypt
certbot --nginx -d example.com -d www.example.com --email admin@example.com --agree-tos --non-interactive
echo "0 2,14 * certbot renew --quiet --deploy-hook 'systemctl reload nginx'" | tee /etc/cron.d/certbot-renew
File permissions
DOCROOT="/var/www/example.com"
find "$DOCROOT" -type d -exec chmod 755 {} \; ; find "$DOCROOT" -type f -exec chmod 644 {} \;
chmod 600 "$DOCROOT/.env"; chown -R www-data:www-data "$DOCROOT"
chmod 770 "$DOCROOT/storage/"{uploads,cache,sessions}
SELinux
semanage fcontext -a -t httpd_sys_content_t "$DOCROOT(/.*)?"
semanage fcontext -a -t httpd_sys_rw_content_t "$DOCROOT/storage(/.*)?"
restorecon -Rv "$DOCROOT"
AppArmor — create profile restricting PHP-FPM to web dirs only
aa-enforce /etc/apparmor.d/php-fpm-www
flowchart LR
Dev[Push] --> CI[CI: Lint+Test+Build]
CI --> Reg[Container Registry]
Reg --> Stg[Staging Deploy]
Stg -->|Health OK| Gat{Gate}
Gat -->|Blue-Green| BG[Switch Traffic]
Gat -->|Canary| CAN[10%→100%]
BG --> Prod[Production]
CAN --> Prod
Prod --> Mon[Monitor]
Mon -->|Anomaly| Roll[Auto Rollback]
#!/bin/bash
set -euo pipefail
CUR=$(cat /opt/deploy/current-color 2>/dev/null || echo "blue")
NEW=$([ "$CUR" = "blue" ] && echo "green" || echo "blue")
docker pull registry.example.com/app:latest
docker tag registry.example.com/app:latest app:$NEW
docker-compose up -d app-$NEW
for i in $(seq 1 30); do
curl -sf "http://localhost:808${NEW: -1}/health" | grep -q "OK" && break
[ "$i" -eq 30 ] && { echo "FAILED"; exit 1; }; sleep 2
done
sed -i "s/app-$CUR/app-$NEW/g" /etc/nginx/conf.d/upstream.conf
nginx -t && systemctl reload nginx
echo "$NEW" > /opt/deploy/current-color
sleep 10; docker-compose stop app-$CUR
services:
nginx:
image: nginx:1.25-alpine; ports: ["80:80","443:443"]
volumes: [./nginx/conf.d:/etc/nginx/conf.d:ro, ./src:/var/www/html:ro, certs:/etc/letsencrypt:ro]
depends_on: [php-fpm]; networks: [frontend, backend]
php-fpm:
image: php:8.2-fpm-alpine
volumes: [./src:/var/www/html, ./php/php.ini:/usr/local/etc/php/php.ini:ro]
depends_on: [mariadb, redis]; networks: [backend]
mariadb:
image: mariadb:10.11
environment: { MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASS}", MYSQL_DATABASE: "${DB_NAME}", MYSQL_USER: "${DB_USER}", MYSQL_PASSWORD: "${DB_PASS}" }
volumes: [db-data:/var/lib/mysql]; networks: [backend]
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes: [redis-data:/data]; networks: [backend]
certbot:
image: certbot/certbot
volumes: [certs:/etc/letsencrypt, webroot:/var/www/certbot]
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h; done'"
volumes: { db-data: {}, redis-data: {}, certs: {}, webroot: {} }
networks: { frontend: {}, backend: { internal: true } }
# /etc/systemd/system/webapp.service
[Unit] Description=WebApp; After=network.target mariadb.service
[Service] Type=simple; User=www-data; WorkingDirectory=/var/www/example.com
ExecStartPre=/var/www/example.com/artisan config:cache
ExecStart=/usr/bin/php8.2 artisan serve --host=127.0.0.1 --port=8080
Restart=on-failure; NoNewPrivileges=true; ProtectSystem=strict; PrivateTmp=true
[Install] WantedBy=multi-user.target
#!/bin/bash
/usr/local/bin/backup-stack.sh
BDIR="/backup/$(date +%Y-%m-%d_%H%M%S)"; mkdir -p "$BDIR"
mysqldump --single-transaction --routines --all-databases | gzip > "$BDIR/db.sql.gz"
tar czf "$BDIR/files.tar.gz" -C /var/www example.com/
tar czf "$BDIR/configs.tar.gz" /etc/nginx /etc/php /etc/mysql /etc/fail2ban
tar czf - -C / "$BDIR" | gpg --symmetric --cipher-algo AES256 --batch --passphrase "$BACKUP_KEY" \
| aws s3 cp - "s3://backups/$(basename "$BDIR").tar.gz.gpg"
find /backup -maxdepth 1 -type d -mtime +30 -exec rm -rf {} \;
# monitoring/docker-compose.yml
services:
prometheus:
image: prom/prometheus:v2.48.0
volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro, prom-data:/prometheus]
ports: ["9090:9090"]
grafana:
image: grafana/grafana:10.2.0
environment: { GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_PASS}" }
volumes: [grafana-data:/var/lib/grafana]; ports: ["3000:3000"]
alertmanager:
image: prom/alertmanager:v0.26.0
volumes: [./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro]; ports: ["9093:9093"]
node-exporter:
image: prom/node-exporter:v1.7.0
volumes: [/proc:/host/proc:ro, /sys:/host/sys:ro, /:/rootfs:ro]
command: --path.rootfs=/rootfs --path.procfs=/host/proc; ports: ["9100:9100"]
nginx-exporter:
image: nginx/nginx-prometheus-exporter:1.1.0
environment: { SCRAPE_URI: "http://nginx:8080/stub_status" }; ports: ["9113:9113"]
mysqld-exporter:
image: prom/mysqld-exporter:v0.15.1
environment: { DATA_SOURCE_NAME: "exporter:${DB_EXP_PASS}@(mariadb:3306)/" }; ports: ["9104:9104"]
volumes: { prom-data: {}, grafana-data: {} }
groups:
- name: stack
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 2m; labels: { severity: critical }
- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2.0
for: 5m; labels: { severity: warning }
- alert: DBPoolExhausted
expr: mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.85
for: 3m; labels: { severity: warning }
- alert: FPMMaxChildrenReached
expr: phpfpm_max_children_reached_total > 0
for: 1m; labels: { severity: critical }
- alert: SSLCertExpirySoon
expr: ssl_cert_expiry_timestamp_seconds - time() < 7*86400
for: 1h; labels: { severity: warning }
# Nginx stub status
location /stub_status { stub_status; access_log off; allow 127.0.0.1; deny all; }
PHP-FPM status
location ~ ^/(fpm-status|fpm-ping)$ { access_log off; allow 127.0.0.1; deny all;
fastcgi_pass php-fpm; include fastcgi_params; }
# /etc/filebeat/filebeat.yml
filebeat.inputs:
- type: log; paths: [/var/log/nginx/access.log, /var/log/nginx/error.log]
fields: { service: nginx, environment: production }
- type: log; paths: [/var/log/mysql/error.log, /var/log/mysql/mariadb-slow.log]
fields: { service: mysql, environment: production }
output.elasticsearch: { hosts: ["elasticsearch:9200"] }
| Symptom | Cause | Fix |
|---------|-------|-----|
| 502 Bad Gateway | FPM pool exhausted | ↑ pm.max_children; check slowlog |
| 504 Timeout | Slow PHP/DB | ↑ fastcgi_read_timeout; optimize query |
| 413 Entity Too Large | Body size limit | client_max_body_size 25M; |
| 403 on uploads | Wrong perms/SELinux | chown www-data + restorecon -R |
| Connection refused :9000 | FPM down | systemctl restart php8.2-fpm |
| Slow first request | Cold ondemand pool | Use pm = dynamic |
| MySQL gone away | Exceeded wait_timeout | App reconnect logic |
| Worker memory leak | PHP leak | Lower pm.max_requests to 500 |
| .htaccess ignored | AllowOverride None | Set AllowOverride All |
systemctl status nginx php8.2-fpm mariadb --no-pager
df -h / /var; free -h
ss -tlnp | grep -E '80|443|3306|9000'
tail -50 /var/log/nginx/error.log
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
curl -s http://localhost/fpm-status | grep -E "idle|active|max"
Block attacker: iptables -A INPUT -s IP -j DROP
SSL expiry: echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates
worker_processes auto, epoll, sendfile, gzip; hide server_tokenspm = dynamic; max_children sized to RAM; max_requests = 1000; OPcache oninnodb_buffer_pool_size ≈ 70% RAM; slow query log; flush_log_at_trx_commit = 2