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#!/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#!/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 в будущем.
#!/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
EOFPin-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-клиента с UID0(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.