Развёртывание PHP-проекта на VPS с нуля: структура, окружение, CI/CD

Как поднять PHP-проект на VPS так, чтобы не бояться обновлений: правильная структура каталогов, Nginx + PHP-FPM, безопасное хранение конфигов, очереди и cron, а главное – CI/CD с атомарным переключением релиза и быстрым откатом.

Зачем вообще VPS, если "и так работает на хостинге"

У PHP есть одна приятная особенность: даже сложный проект можно "завести" на удивление быстро. Именно поэтому многие начинают со shared-хостинга и долго держатся за него, пока не упираются в ограничения: версии PHP "как у всех", странные лимиты по процессам, отсутствие нормальной фоновой обработки, неудобные деплои и вечное "не трогайте прод, он хрупкий".

Переезд на VPS обычно происходит не из любви к администрированию, а из прагматики: нужен контроль над окружением, предсказуемость релизов и возможность автоматизировать выпуск так, чтобы он был повторяемым. Если нужен быстрый старт, VPS можно взять у любого провайдера. Например, на VPS.house виртуальный сервер создаётся автоматически в личном кабинете, и это удобно, когда нужно быстро поднять окружение под проект или отдельный staging-контур.

В этой статье я разложу "взрослый" деплой PHP-проекта по полочкам: как подготовить сервер, как организовать файловую структуру под релизы, как настроить Nginx + PHP-FPM и где в этой истории находится CI/CD. Без воды, но с рабочими примерами.

Развёртывание PHP-проекта на VPS с нуля – структура, окружение, CI/CD без боли

Архитектура, к которой стоит стремиться

Цель не в том, чтобы "просто запустить сайт", а в том, чтобы:

  • деплой был повторяемым (не ручной магией в SSH)
  • релизы переключались атомарно (без промежуточных полусостояний)
  • секреты не ездили в репозитории (никаких .env в Git)
  • можно было откатиться за минуты (а не "восстанавливать на глаз")

Практически это почти всегда приводит к схеме "релизы + shared-данные + симлинк current", которую часто используют в Capistrano/Deployer и корпоративных пайплайнах.

Шаг 0. Выбор базовой ОС и минимальные требования

Для большинства PHP-проектов в продакшене проще всего жить на Debian/Ubuntu: много пакетов, прозрачные обновления, понятные инструкции. Дальше я буду показывать команды под Debian/Ubuntu. На Alma/Rocky/CentOS логика та же, но пакетный менеджер и названия пакетов будут отличаться.

Минимальный здравый старт для небольшого продакшена:

  • 2 vCPU
  • 4-8 GB RAM (зависит от нагрузки, очередей, кешей)
  • быстрый SSD/NVMe
  • статический IPv4, чтобы DNS и интеграции были предсказуемыми

Шаг 1. Подготовка сервера: пользователь, SSH и базовая гигиена

1) Создаём отдельного пользователя для деплоя

adduser deployusermod -aG sudo deploy

2) Переходим на ключи и отключаем парольный вход по SSH

На локальной машине:

ssh-keygen -t ed25519 -C "deploy-key"
ssh-copy-id deploy@SERVER_IP

На сервере редактируем /etc/ssh/sshd_config:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes

Перезапуск:

sudo systemctl restart ssh

3) Firewall: открыть только нужное

Если это веб-сервер, базовый минимум – 22, 80, 443. Для Ubuntu удобно через UFW:

sudo apt update

sudo apt install ufw -y

sudo ufw default deny incoming

sudo ufw default allow outgoing

sudo ufw allow 22/tcp

sudo ufw allow 80/tcp

sudo ufw allow 443/tcp

sudo ufw enable

sudo ufw status

4) Fail2ban как страховка от перебора

sudo apt install fail2ban -y

sudo systemctl enable --now fail2ban

Это не "щит от всего", но заметно снижает шум и риск простых атак.

Шаг 2. Ставим стек: Nginx, PHP-FPM, расширения, Composer

sudo apt update

sudo apt install nginx -y

Пример для PHP 8.2 (пакеты зависят от репозиториев вашей ОС)

sudo apt install php-fpm php-cli php-mbstring php-xml php-curl php-zip php-gd php-intl php-bcmath php-mysql -y

Composer

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"

php composer-setup.php --install-dir=/usr/local/bin --filename=composer

rm composer-setup.php

nginx -v

php -v

composer -V

Пара замечаний из практики:

  • ставьте только те расширения, которые реально нужны проекту (меньше поверхность атаки и меньше сюрпризов)
  • проверьте лимиты upload_max_filesize и post_max_size, если проект принимает файлы
  • не забывайте про opcache (часто он уже включён, но настройки по умолчанию не всегда оптимальны)

Шаг 3. Правильная структура каталогов: релизы, shared и current

Вот схема, которая выдерживает годы эксплуатации:

/var/www/myapp/
releases/
20251216_120501/
20251216_131045/
shared/
.env
storage/
uploads/
logs/
current -> /var/www/myapp/releases/20251216_131045

Зачем так усложнять:

  • releases – каждый деплой кладёт новый "снимок" кода в отдельную папку
  • shared – данные, которые должны переживать релизы: .env, загрузки пользователей, кеши, storage, логи
  • current – симлинк на активный релиз. Переключение симлинка – атомарная операция, поэтому фронт не видит "полураскатанного" состояния

Создаём:

sudo mkdir -p /var/www/myapp/{releases,shared}
sudo chown -R deploy:www-data /var/www/myapp
sudo chmod -R 2755 /var/www/myapp

Шаг 4. Nginx: конфиг без ловушек

Пример /etc/nginx/sites-available/myapp.conf:

server {
listen 80;
server_name example.com;

root /var/www/myapp/current/public;
index index.php;

client_max_body_size 50m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm-myapp.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

location ~* \.(jpg|jpeg|png|gif|svg|css|js|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
access_log off;
}
}

Включаем сайт:

sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Обратите внимание: root указывает на current/public. Это ключ к "атомарным" релизам.

Шаг 5. Отдельный пул PHP-FPM под проект

Создаём /etc/php/8.2/fpm/pool.d/myapp.conf:

[myapp]
user = www-data
group = www-data

listen = /run/php/php8.2-fpm-myapp.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6

php_admin_value[error_log] = /var/www/myapp/shared/logs/php-fpm-error.log
php_admin_flag[log_errors] = on
php_value[memory_limit] = 512M

Создаём папку логов:

mkdir -p /var/www/myapp/shared/logs
sudo systemctl restart php8.2-fpm

Почему отдельный пул – хорошая идея:

  • проще отлавливать проблемы конкретного проекта
  • можно тюнить лимиты и количество процессов под нагрузку
  • меньше шанс, что соседний сайт "утопит" ваш FPM общими настройками

Шаг 6. Секреты и конфиги: где живёт .env

Правило: .env не должен быть в репозитории. Он должен быть в shared/.env, а в релизе – симлинк.

Пример:

nano /var/www/myapp/shared/.env
chmod 640 /var/www/myapp/shared/.env
chown deploy:www-data /var/www/myapp/shared/.env

На этапе деплоя делаем:

ln -sfn /var/www/myapp/shared/.env /var/www/myapp/releases/RELEASE_ID/.env

То же самое со storage, uploads и другими папками, которые не должны "сбрасываться" при релизе.

Шаг 7. SSL и базовые заголовки

Самый практичный путь – Let’s Encrypt + certbot.

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com

После этого добавьте минимум здравых заголовков (осторожно, тестируйте, чтобы не сломать интеграции):

add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header Referrer-Policy strict-origin-when-cross-origin;

HSTS включайте только когда уверены, что HTTPS настроен корректно и будет поддерживаться.

Шаг 8. Очереди, cron и "всё то, что работает не по запросу"

PHP-проект редко живёт только HTTP-запросами. Обычно есть:

  • очереди (письма, уведомления, обработка задач)
  • cron-задачи (очистка, пересчёты, синхронизации)
  • воркеры

Пример systemd-сервиса для воркера (условно для Laravel, но идея универсальна) /etc/systemd/system/myapp-worker.service:

[Unit]
Description=MyApp Queue Worker
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/php /var/www/myapp/current/artisan queue:work --sleep=1 --tries=3
Restart=alwaysRestartSec=2

[Install]
WantedBy=multi-user.target

Запуск:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-worker
sudo systemctl status myapp-worker

Cron лучше держать минимальным и понятным. Например, один entrypoint, который вызывает планировщик приложения.

Шаг 9. CI/CD: что автоматизировать в первую очередь

CI/CD для PHP обычно даёт максимальную пользу, если вы автоматизировали три вещи:

  1. проверки качества (линтеры, статанализ, тесты)
  2. сборку релиза (composer install, подготовка артефактов)
  3. доставку на сервер и атомарное переключение current

Ниже пример GitHub Actions (упрощённый, но рабочий по логике). .github/workflows/deploy.yml:

name: deploy
on:
push:
branches: [ "main" ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
– uses: actions/checkout@v4
– name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
tools: composer
– name: Install deps
run: composer install --no-interaction --prefer-dist
– name: Run tests
run: composer test # или phpunit/pest – что у вас в проекте
– name: Build release archive
run: |
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
tar -czf release.tar.gz --exclude=.git --exclude=storage .
– name: Upload and deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
RELEASE_ID="${{ env.RELEASE_ID }}"
APP_DIR="/var/www/myapp"
REL_DIR="$APP_DIR/releases/$RELEASE_ID"
mkdir -p "$REL_DIR"

Сама доставка архива может быть отдельным шагом через scp/rsync. Для ясности часто делают так: upload через rsync, затем серверный скрипт деплоя.

Серверный скрипт деплоя (ключевой кусок)

/var/www/myapp/shared/deploy.sh:

#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/var/www/myapp"
RELEASE_ID="$1"
REL_DIR="$APP_DIR/releases/$RELEASE_ID"

# 1) Разархивировали/залили код в $REL_DIR заранее

# 2) Симлинки на shared
ln -sfn "$APP_DIR/shared/.env" "$REL_DIR/.env"
mkdir -p "$APP_DIR/shared/storage" "$APP_DIR/shared/uploads"
ln -sfn "$APP_DIR/shared/storage" "$REL_DIR/storage"
ln -sfn "$APP_DIR/shared/uploads" "$REL_DIR/public/uploads"
mkdir -p "$APP_DIR/shared/logs"

# 3) Установка зависимостей на сервере (если не собираете артефакт полностью в CI)
cd "$REL_DIR"
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader

# 4) Миграции (включайте осторожно и осознанно)
# php artisan migrate --force

# 5) Переключаем current атомарно
ln -sfn "$REL_DIR" "$APP_DIR/current"

# 6) Мягко перезагружаем сервисы
sudo systemctl reload nginx
sudo systemctl reload php8.2-fpm || true
echo "Deployed release: $RELEASE_ID"

Права:

chmod +x /var/www/myapp/shared/deploy.sh

Главная идея: релиз готовится в отдельной папке, и только в конце "включается" через симлинк.

Шаг 10. Деплой без простоя: что может пойти не так

"Без простоя" в вебе чаще означает: пользователь не видит 502/500 и не попадает в сломанный state. Это достигается не магией, а двумя принципами:

  1. атомарное переключение релиза (симлинк current)
  2. совместимость миграций (сначала код должен уметь работать со старой схемой, затем миграция, затем уборка)

Если вы делаете деструктивные миграции "в лоб" – простои будут не из-за VPS, а из-за логики релиза.

Логи, мониторинг и "где смотреть, когда всё странно"

Минимум, который реально спасает часы:

  • доступ и error логи Nginx
  • error лог PHP-FPM пула
  • логи приложения (в shared)
  • простой health-check endpoint (например /health), который проверяет базовые зависимости

Примеры команд:

sudo tail -n 200 /var/log/nginx/error.log

sudo journalctl -u php8.2-fpm -n 200 --no-pager

sudo tail -n 200 /var/www/myapp/shared/logs/php-fpm-error.log

Резервные копии: спросите это до того, как понадобится восстановление

Практика взросления проекта выглядит так: один раз вы теряете часть данных или ломаете окружение, после чего бэкапы становятся "обязательной гигиеной".

Минимальный набор вопросов к провайдеру:

  • как часто делаются бэкапы и сколько точек хранится
  • можно ли восстановить сервер целиком и сколько это занимает по времени
  • есть ли регулярные автоматические снепшоты (идеально – по расписанию и перед изменениями)
  • как устроено восстановление: через панель, тикет или автоматом

Если хочется быстро тестировать окружения, staging и временные копии продакшена, удобны платформы, где сервер можно быстро пересоздавать и менять ресурсы без бюрократии. Например, на vps.house есть автоматизация управления сервером и конфигурацией, что хорошо сочетается с CI/CD-подходом, когда окружение становится частью процесса, а не разовой "ручной настройкой".

Чек-лист "готово к продакшену"

  • SSH только по ключам, root-login запрещён

  • Firewall: открыты только нужные порты

  • Nginx настроен на current/public

  • PHP-FPM отдельным пулом, логи в shared

  • .env и storage в shared, не в репозитории

  • Есть cron/воркеры как systemd-сервисы

  • SSL настроен, редирект на HTTPS работает

  • CI прогоняет тесты и деплоит релизы атомарно

  • Есть стратегия миграций и понятный rollback

  • Настроены бэкапы и проверено восстановление

Финал: почему эта схема экономит время, а не "добавляет администрирования"

Развёртывание PHP-проекта на VPS с нуля кажется долгим ровно до первого серьёзного релиза. Когда у вас есть понятная структура релизов, shared для данных, атомарное переключение и CI/CD, продакшен перестаёт быть "священной коровой". Вы выпускаете изменения чаще, спокойнее и с меньшим риском для пользователей.

Если нужно быстро поднять инфраструктуру под такие практики, начать можно с любой платформы аренды. Например, арендовать виртуальный сервер под PHP-проект и построить вокруг него релизную схему можно на VPS.house – дальше всё решают уже не бренд и не тариф, а дисциплина окружения, релизов и автоматизации.

loader
Комментарии
Новый комментарий

Логические задачи с собеседований