Развёртывание 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. Без воды, но с рабочими примерами.
Архитектура, к которой стоит стремиться
Цель не в том, чтобы "просто запустить сайт", а в том, чтобы:
- деплой был повторяемым (не ручной магией в 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 обычно даёт максимальную пользу, если вы автоматизировали три вещи:
- проверки качества (линтеры, статанализ, тесты)
- сборку релиза (composer install, подготовка артефактов)
- доставку на сервер и атомарное переключение 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. Это достигается не магией, а двумя принципами:
- атомарное переключение релиза (симлинк current)
- совместимость миграций (сначала код должен уметь работать со старой схемой, затем миграция, затем уборка)
Если вы делаете деструктивные миграции "в лоб" – простои будут не из-за 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 – дальше всё решают уже не бренд и не тариф, а дисциплина окружения, релизов и автоматизации.


Комментарии