📚 BusinessExplain 🏠 Home 📊 Analytics
← Back to Skills

Lemp Lamp Mastery

💻 Coding & Engineering 9 min read v1.0.0
📋 Contents

LEMP & LAMP Stack Mastery

> Production-grade guide to building, securing, deploying, and monitoring Linux web stacks — Nginx/Apache, PHP-FPM/module, MariaDB/MySQL.


1. Architecture Overview

LEMP vs LAMP Architecture

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

Request Flow: Nginx → PHP-FPM

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.


2. LEMP Stack — Nginx + PHP-FPM + MariaDB

Nginx Production Config

# /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/*;
}

Virtual Host (SSL + PHP + Security)

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'; }
}

Reverse Proxy + WebSocket

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; }
}

PHP-FPM Pool Configuration

# /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.

MariaDB Optimization

# /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

3. LAMP Stack — Apache + PHP + MariaDB

Apache MPM Event + Virtual Host

# 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"
    

.htaccess — Rewrite + Security + Caching

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

mod_php vs PHP-FPM

| 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 |


4. Server Security

UFW + SSH Hardening + fail2ban

# 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

SSL + Permissions + SELinux/AppArmor

# 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


5. Deployment Strategies

Deployment Pipeline

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]

Blue-Green Deployment

#!/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

Docker Compose — Full LEMP Stack

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 } }

Systemd + Backup

# /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 {} \;

6. Monitoring & Performance

Prometheus + Grafana Stack

# 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: {} }

Alert Rules

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/FPM Metrics + Log Aggregation

# 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"] }

7. Edge Cases & Troubleshooting

| 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 |

Emergency Runbook

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

Performance Tuning Checklist

  • [ ] Nginx: worker_processes auto, epoll, sendfile, gzip; hide server_tokens
  • [ ] PHP-FPM: pm = dynamic; max_children sized to RAM; max_requests = 1000; OPcache on
  • [ ] MariaDB: innodb_buffer_pool_size ≈ 70% RAM; slow query log; flush_log_at_trx_commit = 2
  • [ ] Firewall: UFW default deny; SSH limit; fail2ban sshd+nginx jails
  • [ ] SSL: TLSv1.2+; HSTS; certbot auto-renew cron
  • [ ] Backups: daily DB dump; offsite S3; test restore quarterly
  • [ ] Monitoring: Prometheus+Grafana; alerts for 5xx/latency/pool/SSL
  • [ ] Logs: Filebeat → Elasticsearch; structured JSON format
  • [ ] Deploy: blue-green/canary; health check gates; rollback plan
  • 💬 Ask about Lemp Lamp Mastery