Staging-окружение на VDS — один из самых недооценённых инструментов для безопасных релизов. Его или вообще нет, или это «какой-то левый сервер», где конфиги руками подкручивали годами, и уже никто не уверен, насколько он похож на прод.
Здоровый подход сегодня — описывать инфраструктуру как код. Для VDS это почти всегда означает Ansible: простой вход, не требуется агент, хорошо подходит для управления Nginx, PHP-FPM и Postgres. Ниже разберём, как навести порядок: сделать повторяемый staging, максимально похожий на прод, но безопасный и управляемый.
Что мы хотим от staging на VDS
Прежде чем писать плейбуки и роли, важно зафиксировать требования к staging-окружению. Это убережёт от ситуации, когда staging «как бы есть», но по факту на нём нельзя проверить даже банальный релиз.
Типичный чек-лист для staging:
- Максимально те же версии софта, что и на проде: Nginx, PHP-FPM, Postgres, система (Debian/Ubuntu).
- Та же топология: хотя бы один frontend (Nginx) и один backend (PHP-FPM + Postgres). В минимальном варианте — всё на одном VDS, но с теми же сервисами.
- Отдельная база данных: изолированные схемы и пользователи, чтобы никакие тесты не трогали продовые данные.
- Те же механизмы деплоя: та же сборка артефактов, те же миграции БД, максимально такие же хендлеры перезапуска сервисов.
- Безопасность: staging не должен «торчать» в интернет как прод, если этого не требуется, а уж точно не должен содержать продовые токены и ключи.
Ansible здесь идеально ложится: мы описываем роли для Nginx, PHP-FPM, Postgres один раз и применяем их и к продовым VDS, и к staging. Разница — только в inventory и переменных окружения.
Базовая структура Ansible-проекта для staging и prod
Самая частая ошибка — смешивать переменные и хосты прод/staging так, что через полгода уже никто не понимает, куда применяется конкретный плейбук. Начнём со структуры директорий, которая явно разводит окружения.
project-ansible/
ansible.cfg
inventories/
prod/
hosts.ini
group_vars/
all.yml
web.yml
db.yml
staging/
hosts.ini
group_vars/
all.yml
web.yml
db.yml
roles/
common/
nginx/
php_fpm/
postgres/
app/
playbooks/
site.yml
staging.yml
prod.yml
Здесь важные моменты:
inventories/prodиinventories/stagingфизически разделены. Это снижает риск случайно применить плейбук по staging-инвентори к продовым серверам.group_varsраздельные: можно спокойно ставить другие лимиты по ресурсам, включать/выключать дебаг, менять домены и порты.- Роли общие:
roles/nginx,roles/php_fpm,roles/postgresодинаково работают и на staging, и на проде.
Простой пример плейбука для staging:
---
- name: Staging environment
hosts: all
become: true
roles:
- role: common
- role: nginx
tags: [nginx]
- role: php_fpm
tags: [php]
- role: postgres
tags: [postgres]
- role: app
tags: [app]
Запуск для staging:
ansible-playbook -i inventories/staging/hosts.ini playbooks/staging.yml

Inventory: как описать VDS для staging
Inventory можно вести и в YAML, и в INI. Для начала возьмём простой INI-вариант для staging.
[web]
staging-web-1 ansible_host=203.0.113.10 ansible_user=deployer
[db]
staging-db-1 ansible_host=203.0.113.11 ansible_user=deployer
[all:vars]
ansible_python_interpreter=/usr/bin/python3
Если staging у вас компактный и всё крутится на одном VDS, это может выглядеть так:
[web]
staging-all-1 ansible_host=203.0.113.20 ansible_user=deployer roles="web,db"
[db]
staging-all-1
[all:vars]
ansible_python_interpreter=/usr/bin/python3
Дальше уже в ролях можно ориентироваться на группы web/db, а не на конкретные хосты. Это позволит безболезненно разнести компоненты по отдельным VDS в будущем.
Переменные для staging: версии, домены, лимиты
Staging не обязан быть один в один, как прод, по ресурсам, но должен совпадать по версиям и архитектуре приложения. Разделим переменные по группам, чтобы не устраивать свалку из десятков YAML-файлов.
Общие переменные staging
Файл inventories/staging/group_vars/all.yml:
project_name: myapp
project_env: staging
nginx_version: stable
php_fpm_version: "8.2"
postgres_version: "15"
app_domain: staging.example.internal
app_debug: true
app_log_level: debug
# например, другой путь к артефактам
app_release_channel: staging
Здесь фиксируем отличия staging:
- Домен или поддомен другой.
- Логи и debug включены сильнее.
- Можно подключать другие S3-бакеты, очереди и т.п., чтобы не трогать продовые ресурсы.
Переменные для Nginx и PHP-FPM на staging
Файл inventories/staging/group_vars/web.yml:
nginx_worker_processes: auto
nginx_worker_connections: 1024
php_fpm_pm: dynamic
php_fpm_pm_max_children: 10
php_fpm_pm_start_servers: 3
php_fpm_pm_min_spare_servers: 2
php_fpm_pm_max_spare_servers: 5
На staging обычно не нужны такие же высокие лимиты, как на проде. Можно спокойно уменьшить nginx_worker_connections и php_fpm_pm_max_children, чтобы не тратить VDS-ресурсы впустую.
Переменные для Postgres на staging
Файл inventories/staging/group_vars/db.yml:
postgres_listen_addresses: "127.0.0.1"
postgres_port: 5432
postgres_max_connections: 50
postgres_shared_buffers: "512MB"
postgres_work_mem: "8MB"
postgres_maintenance_work_mem: "128MB"
postgres_db_name: myapp_staging
postgres_db_user: myapp_staging
postgres_db_password: "change_me_in_vault"
Пароли и другие чувствительные данные в реальном проекте лучше вынести в Ansible Vault или внешнее хранилище секретов, а не хранить в открытом виде.
Роль для Nginx: общая логика, разные окружения
Создадим роль roles/nginx, которая умеет:
- устанавливать Nginx нужной версии;
- конфигурировать общий
nginx.confс параметрами worker-процессов; - создавать vhost для приложения с разными доменами и путями для staging/prod.
Пример таска для шаблона конфига сайта:
- name: Deploy nginx site config
template:
src: site.conf.j2
dest: /etc/nginx/sites-available/{{ project_name }}.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable nginx site
file:
src: /etc/nginx/sites-available/{{ project_name }}.conf
dest: /etc/nginx/sites-enabled/{{ project_name }}.conf
state: link
notify: Reload nginx
Пример шаблона site.conf.j2 (упрощённый, без SSL и лишних настроек):
server {
listen 80;
server_name {{ app_domain }};
root /var/www/{{ project_name }}/current/public;
index index.php index.html;
access_log /var/log/nginx/{{ project_env }}-{{ project_name }}.access.log;
error_log /var/log/nginx/{{ project_env }}-{{ project_name }}.error.log;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php{{ php_fpm_version }}-fpm.sock;
}
}
Ключевой момент: staging и prod используют один и тот же шаблон, но разные переменные app_domain, project_env, php_fpm_version. Это гарантирует одинаковую логику маршрутизации и обращения к PHP-FPM.
Роль PHP-FPM: пулы под окружение
Роль roles/php_fpm настраивает пул для приложения. Хорошая практика — включать в имя пула окружение, чтобы не путаться и не пересекать конфигурации.
Пример таска:
- name: Deploy PHP-FPM pool for app
template:
src: app-pool.conf.j2
dest: /etc/php/{{ php_fpm_version }}/fpm/pool.d/{{ project_env }}-{{ project_name }}.conf
owner: root
group: root
mode: "0644"
notify: Reload php-fpm
Шаблон app-pool.conf.j2 (фрагмент):
[{{ project_env }}-{{ project_name }}]
user = www-data
group = www-data
listen = /run/php/php{{ php_fpm_version }}-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = {{ php_fpm_pm }}
pm.max_children = {{ php_fpm_pm_max_children }}
pm.start_servers = {{ php_fpm_pm_start_servers }}
pm.min_spare_servers = {{ php_fpm_pm_min_spare_servers }}
pm.max_spare_servers = {{ php_fpm_pm_max_spare_servers }}
php_admin_value[display_errors] = {{ 'On' if project_env == 'staging' else 'Off' }}
php_admin_value[log_errors] = On
В этом примере мы прямо в шаблоне завязываемся на project_env, чтобы на staging включить display_errors. Можно сделать и через переменные, если вы хотите жёстко контролировать такие режимы.

Роль Postgres: отдельные БД и пользователи для staging
Роль roles/postgres отвечает за базу. Минимальный набор задач:
- установка нужной версии Postgres;
- базовый
postgresql.confс тюнингом под VDS; - создание пользователя и базы для приложения;
- настройка
pg_hba.confдля безопасного доступа.
Пример создания пользователя и базы:
- name: Ensure PostgreSQL user exists
become_user: postgres
postgresql_user:
name: "{{ postgres_db_user }}"
password: "{{ postgres_db_password }}"
role_attr_flags: LOGIN
- name: Ensure PostgreSQL database exists
become_user: postgres
postgresql_db:
name: "{{ postgres_db_name }}"
owner: "{{ postgres_db_user }}"
encoding: UTF8
lc_collate: en_US.UTF-8
lc_ctype: en_US.UTF-8
Для staging важно убедиться, что:
- имя базы и пользователя однозначно говорят, что это staging (например,
myapp_staging); postgres_listen_addressesограничены (часто достаточно127.0.0.1или приватной сети VDS);- данные либо анонимизированы, либо вообще сгенерированы отдельно от продовых.
Роль приложения: код, миграции и конфиг
Роль roles/app отвечает за деплой кода и конфиг приложения. Общая логика:
- выкачать артефакт релиза (tar.gz или аналог) по тегу или каналу (staging/prod);
- распаковать в директорию релизов и переключить симлинк
current; - применить миграции к Postgres;
- сгенерировать конфиг (например,
.env) из шаблона.
Фрагмент таска с конфигом:
- name: Render app env file
template:
src: env.j2
dest: /var/www/{{ project_name }}/current/.env
owner: www-data
group: www-data
mode: "0640"
Пример фрагмента шаблона env.j2 для PHP-приложения:
APP_ENV={{ project_env }}
APP_DEBUG={{ 'true' if app_debug else 'false' }}
APP_LOG_LEVEL={{ app_log_level }}
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT={{ postgres_port }}
DB_DATABASE={{ postgres_db_name }}
DB_USERNAME={{ postgres_db_user }}
DB_PASSWORD={{ postgres_db_password }}
Благодаря этому staging и prod используют одну и ту же роль, а различия минимальны и контролируемы переменными.
Секреты для staging: Ansible Vault и не только
Даже если staging считается менее критичным окружением, хранить там пароли в открытом виде — плохая практика. Лучше сразу приучаться к Ansible Vault или внешним секрет-хранилищам. Если вы хотите пойти ещё дальше и держать секреты в Git-репозитории, посмотрите подход с sops и age в материале о GitOps и управлении секретами.
Пример: вынос секретов staging в отдельный файл inventories/staging/group_vars/all.vault.yml:
postgres_db_password: "s3cr3t_staging"
app_secret_key: "random_app_key"
Шифруем:
ansible-vault encrypt inventories/staging/group_vars/all.vault.yml
И подключаем его, например, через include_vars в плейбуке или оставляя Ansible возможность автоматически подхватить файл с расширением .vault.yml, если так принято в вашем проекте.
Запуск плейбука со вводом Vault-пароля:
ansible-playbook -i inventories/staging/hosts.ini playbooks/staging.yml --ask-vault-pass
Как не перепутать staging и prod при запуске Ansible
Человеческий фактор никуда не делся: можно запустить не тот плейбук по не тому inventory. Есть несколько практик, которые помогают уменьшить вероятность ошибок.
- Разные
ansible.cfgдля prod и staging (или хотя бы разные алиасы команд). Например,make deploy-stagingиmake deploy-prodс жёстко прописанными путями к inventory. - Явное указание окружения в плейбуке и проверка переменной
project_env. - Промпт-предупреждение для продового плейбука через
vars_prompt.
Пример простой проверки окружения в плейбуке:
- name: Ensure environment is staging
hosts: all
gather_facts: false
tasks:
- name: Fail if project_env is not staging
fail:
msg: "This playbook should be run only for staging environment"
when: project_env != 'staging'
Аналогично можно сделать жёсткий safeguard в продовом плейбуке, чтобы он не запускался по staging-инвентори.
Staging-данные в Postgres: как их готовить
Отдельная тема — содержимое базы на staging. Вариантов несколько:
- Синтетические данные, генерируемые миграциями или сидерами (например, команды фреймворка Laravel или Symfony).
- Регулярный дамп продовой базы с анонимизацией чувствительных полей.
- Комбинация: структура и часть данных с прода, плюс генерация недостающих.
Автоматизировать этот процесс через Ansible тоже возможно. Например:
- name: Run database seed on staging
become: true
become_user: www-data
args:
chdir: /var/www/{{ project_name }}/current
command: php artisan db:seed --env=staging
when: project_env == 'staging'
Главное — не подключать staging напрямую к продовой БД и не использовать продовые креденшелы. Это частая и очень опасная ошибка, особенно если staging доступен из интернета по доменному имени; в подобных сценариях не забывайте про корректные SSL-сертификаты и жёсткие ACL.
Типичный полный цикл: поднять новый staging VDS с нуля
Соберём всё вместе в сценарий, который пригоден для практики.
- Создаёте один или два VDS под staging (web и db, либо всё на одном) с нужной ОС.
- Прописываете их в
inventories/staging/hosts.ini, как показано выше. - Заполняете
group_varsдля staging: версии Nginx, PHP и Postgres, домены, ресурсы. - Создаёте или дорабатываете роли
nginx,php_fpm,postgres,appтак, чтобы они не содержали «зашитых» окружений. - Выносите секреты staging в Ansible Vault.
- Запускаете плейбук staging и проверяете, что:
- Nginx отвечает по staging-домену,
- PHP-FPM запустился с нужным пулом и лимитами,
- Postgres слушает нужный порт и база
myapp_stagingсоздана, - приложение может подключиться к БД и пройти миграции.
После этого staging можно включать в CI/CD: перед выкатыванием в прод гоняем прогон тестов, проверяем миграции и работоспособность приложения на staging. А если вам нужно без простоя перенести боевой сайт, пригодится отдельный разбор про миграцию без даунтайма — см. материал о переносе сайтов без простоя.
Типичные грабли и как их избегать
Из практики админов и девопсов по staging на VDS с Ansible чаще всего встречаются такие проблемы:
- Роли жёстко хардкодят окружение — например, в шаблоне Nginx встречается
server_name example.comбез переменной. Решение: вытаскивать всё, что отличается между окружениями, вgroup_vars. - Одинаковые имена баз и пользователей для staging и prod. В итоге скрипт или человек легко ошибается. Решение: всегда использовать суффикс или префикс окружения (
myapp_staging,myapp_prod). - Случайное применение плейбука staging к продовым хостам. Решение: раздельные inventory, защитные проверки
project_env, явные make-таргеты. - Staging зависим от продовых внешних сервисов (S3, очереди, платежи) в боевом режиме. Решение: отдельные креденшелы и sandbox-аккаунты, а в крайнем случае — мок-сервисы.
- Разъехавшиеся версии Postgres и PHP между staging и продом. Решение: прописывать версии как переменные в
group_varsи регулярно сверять.
Итоги
Staging на VDS, описанный через Ansible, — это не роскошь, а базовый инструмент для безопасных релизов. Как только вы раскладываете конфигурацию по ролям, разделяете inventory и переменные для staging и prod, жизнь сильно упрощается:
- Новые VDS под staging поднимаются и настраиваются в один плейбук.
- Разработчики получают предсказуемое окружение, близкое к продовому.
- Админы могут быстро править и донастраивать Nginx, PHP-FPM и Postgres в одном месте.
- Релизы через staging становятся нормой: фичи и миграции прогоняются на боевом стеке до выката в прод.
Главное — держать staging и prod на одной технологической базе, но с аккуратно разведёнными переменными, секретами и доступами. Тогда Ansible и VDS полноценно отработают свою роль как основа для управляемых и предсказуемых окружений.


