LEMP (иногда обозначаемый как LNMP) — это акроним, представляющий собой комплекс серверного программного обеспечения. Название образовано от входящих в его состав компонентов: операционной системы Linux, веб-сервера Nginx, системы управления базами данных MariaDB или MySQL, а также языка программирования PHP. Использование буквы «E» вместо «N» объясняется фонетическим произношением Nginx как «Engine-X» (Энджин-Икс), что делает аббревиатуру более удобной для произношения.

По своей сути LEMP является аналогом популярного стека LAMP, который традиционно используется для разработки и развертывания веб-приложений на базе связки Linux, Apache, MySQL и PHP. Благодаря модульной природе таких сборок, базовые компоненты легко взаимозаменяемы. Главное отличие LEMP заключается именно в замене веб-сервера: вместо Apache используется более легкий, но мощный Nginx, который способен обеспечить стабильную и предсказуемую производительность сервера при высоких нагрузках.

Скрипты тестировались на Ubuntu 26.04.1 LTS.

Скрипт 1: Установка пакетов для веб хостинга

Устанавливает репозиторий Ondřej Surý для свежих версий PHP (8.3 и 8.5), снимает лимиты ОС, настраивает сеть под высокие нагрузки и делает заглушку (default site), чтобы сервер скидывал мусорные запросы по IP (ботов). Запуск скрипта на сервере:

wget -qO- https://dieg.net/scripts/lemp-01-init.sh | bash
lemp-01-init.sh [ Raw / Download ]
#!/bin/bash
# Base package installation, Nginx, PHP 8.5, MySQL
# If you need other PHP versions, use Docker.

if [ "$EUID" -ne 0 ]; then echo "Error: Run as root"; exit 1; fi

echo "==> Updating system and installing base utilities..."
apt update && apt upgrade -y
apt install -y software-properties-common curl wget unzip

echo "==> Installing Nginx, PHP 8.5 (from Ubuntu 26.04 default repo), and certbot..."
apt install -y nginx \
    php8.5-fpm php8.5-cli php8.5-mysql php8.5-xml php8.5-curl php8.5-mbstring php8.5-zip \
    php8.5-sqlite3 php8.5-gd php8.5-bcmath php8.5-intl php8.5-imagick  \
    certbot python3-certbot-nginx

echo "==> Installing MySQL Server..."
apt install -y mysql-server

echo "==> Basic MySQL hardening (non-interactive equivalent of mysql_secure_installation)..."
mysql <<'EOF'
DELETE FROM mysql.user WHERE User='';
DROP DATABASE IF EXISTS test;
DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';
FLUSH PRIVILEGES;
EOF

echo "==> Checking MySQL bind-address (should default to 127.0.0.1)..."
grep -q "^bind-address" /etc/mysql/mysql.conf.d/mysqld.cnf && \
    echo "    bind-address: $(grep '^bind-address' /etc/mysql/mysql.conf.d/mysqld.cnf)" || \
    echo "    bind-address not explicitly set — defaults to 127.0.0.1, this is fine"

systemctl restart mysql

echo "==> MySQL root authenticates via unix_socket — log in with: sudo mysql (no password)."
echo "==> For services/panels that require password auth, create a separate user:"
echo '    mysql -e "CREATE USER '"'"'sqladmin'"'"'@'"'"'localhost'"'"' IDENTIFIED WITH caching_sha2_password BY '"'"'YOUR_PASSWORD'"'"'; GRANT ALL PRIVILEGES ON *.* TO '"'"'sqladmin'"'"'@'"'"'localhost'"'"' WITH GRANT OPTION; FLUSH PRIVILEGES;"'

echo "==> Configuring Nginx default site (drop IP traffic)..."
cat > /etc/nginx/sites-available/default <<EOF
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444; # non-standard Nginx status: close the connection with no HTTP response
    # Instantly drops the TCP connection with no response at all,
    # before any request processing happens. For bots/scanners hitting
    # the server directly by IP (not by domain name), this is noticeably
    # cheaper in CPU/file-access terms under high junk traffic volume,
    # and it doesn't leak the nginx signature via the default page.
}
EOF

echo "==> Configuring system metrics server (Nginx stub_status & dynamic PHP-FPM status)..."
cat > /etc/nginx/conf.d/metrics.conf <<'EOF'
server {
    # Listen only on localhost
    listen 127.0.0.1:80;
    server_name 127.0.0.1;

    # Nginx native status
    location = /nginx_status {
        stub_status;
        allow 127.0.0.1;
        deny all;
    }

    # Status for the default "www" pool
    # curl http://127.0.0.1/status/www
    location = /status/www {
        allow 127.0.0.1;
        deny all;
        include fastcgi_params;
        
        fastcgi_param SCRIPT_NAME /status;
        fastcgi_param SCRIPT_FILENAME /status;
        
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
    }

    # Dynamic PHP-FPM status for isolated pools
    # Access via: curl http://127.0.0.1/status/pool_name
    location ~ ^/status/(?<pool>.+)$ {
        allow 127.0.0.1;
        deny all;
        include fastcgi_params;
        
        fastcgi_param SCRIPT_NAME /status;
        fastcgi_param SCRIPT_FILENAME /status;
        
        fastcgi_pass unix:/run/php/php8.5-fpm-$pool.sock;
    }
}
EOF

echo "==> Enabling PHP-FPM status page for default www pool..."
sed -i 's/^;pm.status_path = \/status/pm.status_path = \/status/' /etc/php/8.5/fpm/pool.d/www.conf

echo "==> Restarting services..."
nginx -t && systemctl restart nginx
systemctl restart php8.5-fpm

echo "==> Server initialization complete."

Скрипт 2: Тюнинг VPS сервера

Запуск скрипта на сервере:

wget -qO- https://dieg.net/scripts/lemp-optional-tuning.sh | bash
lemp-optional-tuning.sh [ Raw / Download ]
#!/bin/bash
# 00-optional-tuning.sh: file descriptor limits
# Idempotent — safe to re-run without duplicating entries
#
# NOTE: swap is intentionally NOT configured — OOM will fail hard and
# visibly instead of degrading silently under memory pressure (deliberate choice).
# If swap is added later, also set vm.swappiness = 10 (prefer RAM, use swap
# only as a last resort) — otherwise the default swappiness=60 kicks in too eagerly.

if [ "$EUID" -ne 0 ]; then echo "Error: Run as root"; exit 1; fi

echo "==> Setting file descriptor limits (/etc/security/limits.d/99-custom.conf)..."
cat > /etc/security/limits.d/99-custom.conf <<EOF
* soft nofile 65535
* hard nofile 65535
root soft nofile 65535
root hard nofile 65535
www-data soft nofile 65535
www-data hard nofile 65535
EOF

if [ $(wc -l < /proc/swaps) -gt 1 ]; then
    echo "==> Swap detected — setting vm.swappiness=10 (/etc/sysctl.d/99-swappiness.conf)..."
    echo "vm.swappiness = 10" > /etc/sysctl.d/99-swappiness.conf
    sysctl --system
else
    echo "==> No swap configured — skipping vm.swappiness."
fi

echo "==> Tuning applied. A new session/re-login may be required for the limits to take effect."

Скрипт 3: Добавление домена и пользователя

Этот скрипт принимает параметры, создает SFTP-пользователя, создает персональный PHP-FPM пул под этого пользователя и генерирует конфиг Nginx.

Использование скрипта на сервере:

wget https://dieg.net/scripts/lemp-add-site.sh
chmod +x lemp-add-site.sh
./lemp-add-site.sh dieg.net dieg_admin MyStrongPassword123 [email protected]

Если DNS-записи домена уже смотрят на IP-адрес твоего сервера, скрипт полностью поднимет сайт, получит сертификат, пропишет 443 quic; и перезагрузит Nginx. Если DNS еще не прописаны — скрипт сообщит об ошибке Certbot, но сам сайт на 80 порту (HTTP) будет работать, ожидая ручного запуска Certbot в будущем.

lemp-add-site.sh [ Raw / Download ]
#!/bin/bash
# Creates isolated site user, PHP-FPM pool, Nginx vhost, and configures SSL/QUIC.

if [ "$EUID" -ne 0 ]; then echo "Error: Run as root"; exit 1; fi
if [ "$#" -lt 4 ]; then
    echo "Usage: $0 <domain> <user> <password> <admin_email> [php_version]"
    echo "Example: $0 dieg.net dieg_admin MyPass! [email protected] 8.5"
    exit 1
fi

DOMAIN=$1
SITE_USER=$2
PASS=$3
EMAIL=$4
PHP_VER=${5:-8.5}

# Convert domain to a safe pool name (replace dots with underscores)
POOL_NAME=$(echo "$DOMAIN" | tr '.' '_')

WEB_ROOT="/var/www/$DOMAIN"
FPM_SOCK="/run/php/php$PHP_VER-fpm-$POOL_NAME.sock"
POOL_DIR="/etc/php/$PHP_VER/fpm/pool.d"

if [ ! -d "$POOL_DIR" ]; then
    echo "ERROR: PHP $PHP_VER-fpm doesn't seem to be installed (no $POOL_DIR)."
    exit 1
fi

echo "==> Creating chrooted SFTP user $SITE_USER..."
if ! id -u "$SITE_USER" > /dev/null 2>&1; then
    # Create user without SSH login privileges (SFTP only)
    useradd -m -s /usr/sbin/nologin "$SITE_USER"
    echo "$SITE_USER:$PASS" | chpasswd
else
    echo "User $SITE_USER already exists. Skipping."
fi

echo "==> Creating site directories..."
mkdir -p "$WEB_ROOT/public_html"
mkdir -p "$WEB_ROOT/logs"

# The root directory must be owned by root for Chroot to work securely
chown root:root "$WEB_ROOT"
chmod 755 "$WEB_ROOT"

# Subdirectories belong to the site user
chown -R "$SITE_USER:$SITE_USER" "$WEB_ROOT/public_html"
chown -R "$SITE_USER:$SITE_USER" "$WEB_ROOT/logs"

find "$WEB_ROOT/public_html" -type d -exec chmod 755 {} \;
find "$WEB_ROOT/public_html" -type f -exec chmod 644 {} \;

echo "<?php echo 'PHP ' . PHP_VERSION . ' OK on ' . \$_SERVER['SERVER_NAME']; ?>" > "$WEB_ROOT/public_html/index.php"
chown "$SITE_USER:$SITE_USER" "$WEB_ROOT/public_html/index.php"
chmod 644 "$WEB_ROOT/public_html/index.php"

echo "==> Configuring SSHd for SFTP Chroot (if not already configured)..."
if ! grep -q "Match User $SITE_USER" /etc/ssh/sshd_config; then
    cat >> /etc/ssh/sshd_config <<EOF

# SFTP Chroot for $SITE_USER
Match User $SITE_USER
    ForceCommand internal-sftp
    PasswordAuthentication yes
    ChrootDirectory $WEB_ROOT
    PermitTunnel no
    AllowAgentForwarding no
    AllowTcpForwarding no
    X11Forwarding no
EOF
    systemctl restart sshd
fi

echo "==> Configuring PHP-FPM pool ($PHP_VER)..."
# Tuned for 4 Cores / 4 GB RAM without swap.
cat > "$POOL_DIR/$POOL_NAME.conf" <<EOF
[$POOL_NAME]
user = $SITE_USER
group = $SITE_USER
listen = $FPM_SOCK
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 15
pm.process_idle_timeout = 10s
pm.max_requests = 500

; Integration with our global metrics.conf
pm.status_path = /status

php_admin_value[error_log] = $WEB_ROOT/logs/php-error.log
php_admin_flag[log_errors] = on
EOF

echo "==> Creating Nginx vhost..."
cat > "/etc/nginx/sites-available/$DOMAIN" <<EOF
server {
    listen 80;
    listen [::]:80;
    server_name $DOMAIN;
    
    root $WEB_ROOT/public_html;
    index index.php index.html;

    client_max_body_size 64M;

    access_log $WEB_ROOT/logs/access.log;
    error_log $WEB_ROOT/logs/error.log;

    # Basic Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
    # HTTP/3 (QUIC) support header. Will be uncommented after Certbot.
    # add_header Alt-Svc 'h3=":443"; ma=86400';

    # Block access to hidden files and sensitive data
    location = /wp-config.php { deny all; access_log off; log_not_found off; }
    location = /xmlrpc.php { deny all; access_log off; log_not_found off; }
    location ~ \.(log|sql|tgz|xz|tar\.gz|zip)$ { deny all; access_log off; log_not_found off; }
    location ~ /\. { deny all; access_log off; log_not_found off; }
    
    location = /robots.txt { access_log off; log_not_found off; }
    location = /favicon.ico { access_log off; log_not_found off; }

    # Allow Let's Encrypt / ACME challenge for SSL renewal
    location ~ ^/\.well-known {
        allow all;
        auth_basic off;
        access_log off;
        log_not_found off;
    }

    # Static assets caching & CORS headers
    location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|svg|svgz|webp|woff|woff2|ttf|otf|map|mjs)$ {
        add_header Access-Control-Allow-Origin "*";
        add_header Cache-Control "public, max-age=2592000, immutable";
        expires 30d;
        access_log off;
    }

    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }

    location ~ \.php$ {
        try_files \$uri =404;
        include fastcgi_params;
        fastcgi_pass unix:$FPM_SOCK;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;

        # Timeouts aligned with php.ini max_execution_time = 300
        fastcgi_send_timeout 300s;
        fastcgi_read_timeout 300s;
    }
}
EOF

ln -sf "/etc/nginx/sites-available/$DOMAIN" /etc/nginx/sites-enabled/

echo "==> Restarting PHP and Nginx before SSL..."
systemctl restart "php$PHP_VER-fpm"
nginx -t && systemctl reload nginx

echo "==> Requesting SSL certificate via Certbot..."
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL"

if [ $? -eq 0 ]; then
    echo "==> SSL issued successfully. Enabling HTTP/3 (QUIC)..."
    
    # Inject QUIC directives into the SSL block created by Certbot
    sed -i 's/listen 443 ssl;/listen 443 quic;\n    listen 443 ssl;/g' "/etc/nginx/sites-available/$DOMAIN"
    sed -i 's/listen \[::\]:443 ssl;/listen \[::\]:443 quic;\n    listen \[::\]:443 ssl;/g' "/etc/nginx/sites-available/$DOMAIN"
    
    # Uncomment the Alt-Svc header
    sed -i 's/# add_header Alt-Svc/add_header Alt-Svc/g' "/etc/nginx/sites-available/$DOMAIN"
    
    nginx -t && systemctl reload nginx
else
    echo "==> WARNING: Certbot failed (e.g. DNS not pointing to this IP yet)."
    echo "==> Site is active on HTTP. Run Certbot manually later."
fi

echo "=========================================="
echo "Domain: $DOMAIN"
echo "Root Path: $WEB_ROOT/public_html"
echo "SFTP Login: $SITE_USER (Chrooted securely)"
echo "FPM Pool: $POOL_NAME (Socket: $FPM_SOCK)"
echo "Status URL: curl http://127.0.0.1/status/$POOL_NAME"
echo "=========================================="

Итог по логике

При таком подходе твои сайты жестко изолированы друг от друга на уровне прав процессов ОС. Nginx (от имени www-data) имеет право зайти в /var/www/dieg.net/public_html только за счет того, что на директорию стоят права 755 (Others = Read/Execute). А вот сам PHP-движок внутри пула исполняется строго от имени созданного юзера. Любой залитый через FileZilla скрипт — это собственность этого юзера, и любой сгенерированный самим скриптом кэш/файл тоже принадлежит этому юзеру. Нет никаких конфликтов.

FAQ 1: Какую версию PHP ставить sury.org или из репозитория

Важная деталь, которую стоит знать прежде чем выбирать: Ondřej Surý (автор sury.org) — это и есть тот человек, кто пакует PHP для Debian, откуда его переносят в Ubuntu. Он сам пишет об этом прямо: «официальные пакеты в Debian и Ubuntu — это либо моя работа, либо основаны на ней». То есть это не форк с другим набором патчей — это тот же самый пакующий процесс, просто с разной скоростью публикации и разным горизонтом жизни версии.

Рекомендую устанавливать php из репозитория, а если вам понадобится более старая версия PHP использовать Docker.

packages.sury.org публикует пакеты и для php8.5 под resolute, причём с более новыми патч-релизами, чем в архиве Ubuntu. Это значит: если вы позже добавите sury-репозиторий ради, скажем, php8.4 — apt увидит, что версия php8.5 в sury выше, чем уже установленная из архива Ubuntu, и на следующем apt upgrade молча перетащит ваш уже настроенный php8.5 на сборку sury. Учитывая, что вы уже настроите FPM-пулы, конфиги и т.д. именно под Ubuntu-сборку — да, это реальный риск порвать то, что уже работает (не гарантированно сломает, но патч-уровень и цикл обновлений станут другими, а точки отказа — на maintainer’а sury, а не Canonical).

Решение — APT pinning, запрещающий именно php8.5 брать из sury, при этом остальные версии из sury остаются доступны как обычно:

bash

cat > /etc/apt/preferences.d/no-sury-php85 <<EOF
Package: php8.5*
Pin: origin packages.sury.org
Pin-Priority: -1
EOF

Pin-Priority: -1 — это жёсткий запрет: даже если версия в sury выше, apt никогда не предложит её для пакетов php8.5*, они останутся строго на архиве Ubuntu. При этом php8.2, php8.3, php8.4 из sury будут ставиться и обновляться как обычно — конфликта нет, потому что это разные имена пакетов (php8.4-fpm и php8.5-fpm не пересекаются), а правило пиннинга касается только той версии, что должна остаться неприкосновенной.

Можно спокойно добавить sury потом под другую версию, но добавьте этот pin-файл заранее (хоть сейчас, хоть перед первым добавлением репозитория) — тогда уже сделанные настройки php8.5 гарантированно не тронутся.

FAQ 2: Где пароль root от MySQL?

Пароля у root MySQL нет вообще! Единственный способ получить доступ к root@localhost в MySQL — оказаться root’ом (или через sudo) на самой машине.

Как на самом деле это работает? MySQL-аккаунт root@localhost использует плагин аутентификации auth_socket (в MariaDB — unix_socket, суть та же). Он не сравнивает никакой пароль. Вместо этого при подключении через unix-сокет ядро передаёт MySQL-серверу peer credentials — реальный UID процесса, который стучится (SO_PEERCRED в терминах сокетов). Сервер смотрит: имя ОС-пользователя, от которого пришёл коннект, совпадает с именем MySQL-аккаунта (root)? Совпадает — пускает. Не совпадает — Access denied, вне зависимости от того, что вы введёте.

Поэтому:

  • sudo mysql работает, потому что sudo поднимает процесс mysql-клиента с UID 0 (root), и это совпадает с именем аккаунта root@localhost.
  • Если вы залогинены не как root и наберёте просто mysql -u root без sudo — получите отказ, даже если бы знали какой-то пароль, потому что пароль тут вообще не проверяется.
  • Пароль от shell-root (тот, что вы вводите по SSH или задавали при создании VPS) — вообще никак не связан с этим механизмом. Это два полностью независимых уровня: аутентификация в ОС (SSH/PAM) и аутентификация в MySQL — просто они оба совпали на имени “root” по счастливому стечению обстоятельств, но проверяются они по-разному и хранятся отдельно.

Убедиться самим

sudo mysql -e "SELECT user, host, plugin, authentication_string FROM mysql.user WHERE user='root';"

Увидите что-то вроде: +——+———–+————-+———————–+ | user | host | plugin | authentication_string | +——+———–+————-+———————–+ | root | localhost | auth_socket | | +——+———–+————-+———————–+ Столбец authentication_string — пустой. Хранить там нечего, потому что сравнивать пароль не с чем.

Faq 3: Unix Socket или TCP Port?

Для одиночного VPS единственный правильный вариант — Unix Socket (unix:/run/php/php8.x-fpm.sock).

Почему:

  • Производительность: Unix-сокеты работают на уровне ядра через файловую систему. TCP-порты (127.0.0.1:9000) прогоняют трафик через весь сетевой стек ОС (TCP handshake, инкапсуляция, проверка контрольных сумм). Сокеты дают меньшую задержку (latency) и потребляют меньше ресурсов CPU. Подключение череp TCP может быть медленнее на 20-30% медленнее (latency).
  • Безопасность: Сокеты защищены правами файловой системы ОС (обычно www-data:www-data). TCP-порт по ошибке можно выставить наружу.
  • Стандарт: Это нативный дефолт для связки Nginx + PHP-FPM в Ubuntu.

TCP-порт имеет смысл использовать только в распределенных системах (когда Nginx на одном сервере, а PHP-FPM на другом) или в кластерах.

Faq 4: pm.max_children сколько ставить?

pm.max_children — это ограничение по памяти, а не по CPU. Каждый PHP-FPM воркер — это отдельный процесс, который держит в памяти весь PHP-рантайм + подключенные библиотеки + память для обработки конкретного запроса. Большинство запросов (особенно к WordPress) не грузят процессор постоянно — CPU занят короткими всплесками (выполнение PHP-кода, запрос к БД), а основное время процесс просто ждёт ответа от MySQL, диска, внешнего API и т.д. В это время он не ест CPU, но продолжает занимать RAM и слот в pm-пуле.

Поэтому если сделать max_children = число ядер (скажем, 2-4), вы искусственно ограничите параллелизм там, где реального узкого места по CPU нет. CloudPanel вообще по умолчанию устанавливает pm.max_children =250 - это катастрофа для 4GB RAM без swap. Если на сайт придет трафик, 250 процессов PHP “съедят” около 10 гигабайт памяти ($250 \times 40\text{MB}$). Поскольку swap нет, ядро вызовет OOM-killer и просто “убьет” базу данных MySQL или сам PHP.