IaC перестал быть роскошью для больших команд. Даже небольшой сайтухе или маленькому API выгодно поднимать инфраструктуру как код: меньше ручных ошибок, воспроизводимость окружений, быстрая реконфигурация и возможность спокойно перенести проект на другой VDS при ЧП. В этом руководстве пройдем полный путь: Terraform отвечает за создание VDS и базовых ресурсов, Ansible — за конфигурацию LEMP (Linux, Nginx, MariaDB, PHP-FPM), SSH, firewall, автозапуск сервисов и минимальный деплой.
Задача и границы ответственности
Цель: зафиксировать в коде весь путь от «пусто» до «сайт отдает PHP-страницу на Nginx+PHP-FPM и база доступна только локально». Почему разделяем Terraform и Ansible:
- Terraform: выделение VDS, сеть, ключи (если провайдер поддерживает), базовые параметры ВМ, пользовательские метаданные (cloud-init), выходные данные (IP, имя хоста).
- Ansible: установка пакетов, пользователи и SSH, UFW/nftables, Nginx, PHP-FPM, MariaDB, конфиги и шаблоны, рестарт сервисов, проверка доступности.
Такая граница ответственности упрощает сопровождение: изменить тариф/диски — к Terraform; настроить PHP-модули или Nginx-домены — к Ansible.
Минимальная архитектура LEMP на одном VDS
Для малого проекта берём один сервер на Linux (часто Debian или Ubuntu LTS). Компоненты:
- Nginx как обратный прокси и статик-сервер.
- PHP-FPM для обработки PHP.
- MariaDB или MySQL — одна инстанция локально.
- UFW или nftables — ограничить входящие порты, открыть только SSH и HTTP/HTTPS.
- Отдельный пользователь для деплоя, sudo без пароля для установленного набора команд или с необходимости подтверждения.
Для начала можно обойтись без контейнеризации: монолитный LEMP на одном VDS проще поддерживать и дешевле. При росте нагрузки вы всегда сможете перейти к отдельным узлам и балансировке.
Подготовка локального окружения
Нужны: установленный Terraform и Ansible, рабочая пара SSH-ключей, доступ к провайдеру VDS и токен/ключ API для Terraform-провайдера. На рабочей станции удобно иметь Python 3.10+, pip, и виртуальное окружение для ansible-плагинов, если они нужны.
Структура репозитория
Держите всё в одном репо с чётким разделением:
infra/
terraform/
main.tf
variables.tf
outputs.tf
terraform.tfvars
ansible/
ansible.cfg
inventory.ini
group_vars/
web.yml
host_vars/
vds-1.yml
roles/
common/
tasks/main.yml
nginx/
tasks/main.yml
templates/site.conf.j2
php/
tasks/main.yml
mariadb/
tasks/main.yml
templates/mysql.cnf.j2
playbook.yml
README.md
Так удобно запускать и Terraform, и Ansible независимо: создать сервер, а затем применить конфигурацию. В будущем эту структуру легко «захостить» в CI/CD.

Terraform: ресурсы и выходные переменные
Ниже каркас, абстрактный относительно конкретного облачного провайдера. Смысловой минимум: провайдер, ресурс ВМ (VDS), открытые порты, SSH-ключ, выходные данные IP.
# infra/terraform/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
yourcloud = {
source = "example/yourcloud"
version = ">= 1.0.0"
}
}
}
provider "yourcloud" {
token = var.api_token
region = var.region
}
resource "yourcloud_ssh_key" "local" {
name = var.ssh_key_name
public_key = file(var.ssh_public_key_path)
}
resource "yourcloud_vds" "web" {
name = var.instance_name
image = var.image
flavor = var.flavor
ssh_keys = [yourcloud_ssh_key.local.id]
user_data = file(var.cloud_init_path)
ipv4 = true
ipv6 = false
enable_backups = false
}
Переменные и выходные значения:
# infra/terraform/variables.tf
variable "api_token" { type = string }
variable "region" { type = string }
variable "ssh_key_name" { type = string }
variable "ssh_public_key_path" { type = string }
variable "instance_name" { type = string }
variable "image" { type = string }
variable "flavor" { type = string }
variable "cloud_init_path" { type = string }
# infra/terraform/outputs.tf
output "public_ip" {
value = yourcloud_vds.web.ipv4
}
output "instance_name" {
value = yourcloud_vds.web.name
}
Пример заполнения переменных для локального окружения:
# infra/terraform/terraform.tfvars
api_token = "<your_token>"
region = "ru-1"
ssh_key_name = "laptop-ed25519"
ssh_public_key_path = "~/.ssh/id_ed25519.pub"
instance_name = "vds-1"
image = "ubuntu-22.04"
flavor = "cpu-2-ram-4-disk-40"
cloud_init_path = "../ansible/cloud-init.yml"
Cloud-init для стартовой инициализации
Cloud-init даст базовую безопасность: создаст пользователя, положит SSH-ключ, запретит парольный вход, включит UFW с нужными правилами. Минимальный пример:
# infra/ansible/cloud-init.yml
#cloud-config
users:
- name: deploy
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- <ваш_public_key>
ssh_pwauth: false
package_update: true
packages:
- ufw
runcmd:
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw --force enable
Так мы ещё до Ansible закрываем лишние порты, получаем пользователя для подключения и не держим root-доступ по SSH.
Ansible: инвентори, конфиг и плейбук
После создания ВДС Terraform выдаёт публичный IP, используем его в инвентори. На старте можно внести IP руками, а позже автоматизировать через генерацию inventory из terraform output.
# infra/ansible/ansible.cfg
[defaults]
host_key_checking = False
inventory = inventory.ini
interpreter_python = auto_silent
# infra/ansible/inventory.ini
[web]
vds-1 ansible_host=<PUBLIC_IP> ansible_user=deploy ansible_port=22
Групповые переменные определяют версии пакетов, домен и режимы:
# infra/ansible/group_vars/web.yml
nginx_server_name: example.test
php_version: "8.2"
mysql_root_password: "<смените_на_надёжный>"
app_docroot: /var/www/app/current
Если домен уже есть — укажите его в nginx_server_name. Если нет, заранее оформите и настройте DNS через регистрацию доменов, чтобы сразу проверить сайт по имени.

Пароли и приватные данные держите в зашифрованном виде с ansible-vault. Для примера переменная показана открыто, но в реальном проекте используйте vault и ограничения прав доступа.
Базовый плейбук настраивает всё по ролям:
# infra/ansible/playbook.yml
- name: Configure LEMP on VDS
hosts: web
become: true
roles:
- role: common
- role: php
- role: mariadb
- role: nginx
Роль common: пакеты, системные настройки, SSH
Здесь обновляем пакеты, ставим базовые утилиты и настраиваем SSH-доступ по ключам, без пароля. Если cloud-init это уже сделал, роль всё равно должна быть идемпотентной.
# infra/ansible/roles/common/tasks/main.yml
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Upgrade packages
apt:
upgrade: safe
- name: Install base tools
apt:
name:
- git
- curl
- unzip
- htop
state: present
- name: Ensure SSH password auth disabled
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
create: no
notify: Restart ssh
- name: Ensure UFW rules for SSH/HTTP/HTTPS
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- 22
- 80
- 443
- name: Ensure UFW enabled
ufw:
state: enabled
handlers:
- name: Restart ssh
service:
name: ssh
state: restarted
Роль php: PHP-FPM и расширения
Устанавливаем PHP-FPM и типичные модули. Версию задаём переменной.
# infra/ansible/roles/php/tasks/main.yml
- name: Install PHP-FPM and extensions
apt:
name:
- php{{ php_version }}-fpm
- php{{ php_version }}-cli
- php{{ php_version }}-curl
- php{{ php_version }}-mbstring
- php{{ php_version }}-mysql
- php{{ php_version }}-xml
- php{{ php_version }}-zip
state: present
notify: Restart php-fpm
handlers:
- name: Restart php-fpm
service:
name: php{{ php_version }}-fpm
state: restarted
Роль mariadb: установка и базовая защита
Поднимаем сервер, задаём пароль суперпользователя и запрещаем удалённый доступ по TCP снаружи (слушаем localhost).
# infra/ansible/roles/mariadb/tasks/main.yml
- name: Install MariaDB server
apt:
name: mariadb-server
state: present
- name: Ensure MariaDB started and enabled
service:
name: mariadb
state: started
enabled: true
- name: Set root password
mysql_user:
name: root
host: localhost
password: "{{ mysql_root_password }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
- name: Remove anonymous users
mysql_user:
name: ''
host_all: true
state: absent
login_user: root
login_password: "{{ mysql_root_password }}"
- name: Disallow remote root login
mysql_user:
name: root
host: "%"
state: absent
login_user: root
login_password: "{{ mysql_root_password }}"
- name: Ensure bind-address is localhost
lineinfile:
path: /etc/mysql/mariadb.conf.d/50-server.cnf
regexp: '^bind-address'
line: 'bind-address = 127.0.0.1'
notify: Restart mariadb
handlers:
- name: Restart mariadb
service:
name: mariadb
state: restarted
Роль nginx: сайт, upstream и PHP
Ставим Nginx, разворачиваем корень проекта и конфиг сайта. Перенаправление PHP в сокет PHP-FPM, gzip, базовые заголовки безопасности — это минимум.
# infra/ansible/roles/nginx/tasks/main.yml
- name: Install Nginx
apt:
name: nginx
state: present
- name: Ensure docroot exists
file:
path: "{{ app_docroot }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Place default index.php
copy:
dest: "{{ app_docroot }}/index.php"
content: "<?php phpinfo();"
owner: www-data
group: www-data
mode: "0644"
- name: Deploy site config
template:
src: site.conf.j2
dest: /etc/nginx/sites-available/app.conf
notify: Reload nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/app.conf
dest: /etc/nginx/sites-enabled/app.conf
state: link
notify: Reload nginx
- name: Remove default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
- name: Ensure Nginx started and enabled
service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
service:
name: nginx
state: reloaded
# infra/ansible/roles/nginx/templates/site.conf.j2
server {
listen 80;
server_name {{ nginx_server_name }};
root {{ app_docroot }};
index index.php index.html;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
fastcgi_read_timeout 60s;
}
client_max_body_size 16m;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin-when-cross-origin;
}
Порядок запуска
- Сгенерируйте или укажите SSH-ключ. Проверьте, что публичный ключ указан в переменных Terraform и cloud-init.
- Заполните terraform.tfvars и cloud-init.yml под свои нужды.
- Выполните Terraform: инициализация, план, применить.
- Подставьте публичный IP в inventory.ini, либо сформируйте его автоматически из output.
- Запустите Ansible-плейбук. После завершения сайт отвечает на 80 порту и отдаёт PHP.
cd infra/terraform
terraform init
terraform plan
terraform apply -auto-approve
cd ../ansible
ansible all -m ping
ansible-playbook playbook.yml
Как связать Terraform и Ansible автоматикой
Чтобы не править inventory вручную, можно сгенерировать его на лету. Простой способ: сохранить IP в файл через terraform output и прочитать его в Ansible как переменную окружения или сгенерировать inventory.ini из шаблона.
# Пример получения IP в файл
tf_ip=$(cd infra/terraform && terraform output -raw public_ip)
echo "[web]" > infra/ansible/inventory.ini
echo "vds-1 ansible_host=${tf_ip} ansible_user=deploy ansible_port=22" >> infra/ansible/inventory.ini
Далее просто запускаете ansible-playbook. Такой подход логичен для локального запуска и в CI.
Безопасность и аккуратность
- SSH: отключите парольную аутентификацию, запретите root-логин, используйте ключи и ограничьте список разрешённых IP на уровне firewall, если возможно.
- Firewall: открыты только 22, 80, 443. База — только localhost. При необходимости туннелируйте доступ к базе по SSH.
- Секреты: храните пароли в ansible-vault, не коммитьте terraform.tfvars с токенами. Для Terraform state по возможности используйте удалённый backend с блокировками.
- Идемпотентность: роли Ansible должны быть безопасны при повторном запуске. Тестируйте на чистой ВМ.
Расширения для продакшна
- TLS: автополучение сертификата и настройка HTTPS, редирект с 80 на 443, HSTS. Пригодятся SSL-сертификаты и чек-лист по миграции домена с HSTS — см. статью «Миграция домена, 301, HSTS и SSL» по шагам: как мигрировать домен и включить HSTS.
- Мониторинг: системные метрики и логи Nginx, алерты по доступности.
- Бэкапы: дампы базы по расписанию, ротация, проверка восстановления. Практика с объектным хранилищем — в материале «Бэкапы в S3: restic и borg»: подходы и примеры.
- CI/CD: прогон ansible-lint, terraform validate и автоматический деплой тегированных релизов.
- PHP-FPM и Nginx тюнинг: воркеры, pm, буферы, кэш статики, ограничение скорости запросов.
Типичные ошибки и отладка
- Не сходится SSH-доступ после Terraform. Проверьте cloud-init логи, правильность ключа и открытие 22 порта firewall.
- Плейбук падает на apt update. Проверьте доступ в интернет и актуальность списка репозиториев.
- Nginx 502 Bad Gateway: несовпадение версии сокета PHP-FPM или сервис не стартовал. Убедитесь, что путь сокета правильный и сервис активен.
- База доступна извне: проверьте bind-address, UFW и отсутствие проброса порта на внешнем интерфейсе.
- Дрейф состояния Terraform: не меняйте ручками ресурсы, которыми управляет Terraform, либо документируйте исключения.
Что в итоге
Мы собрали базовый, но полноценный конвейер IaC: Terraform поднимает VDS и задаёт стартовые настройки через cloud-init, Ansible доводит систему до рабочего LEMP с безопасным SSH, закрытым доступом к БД и предсказуемым конфигом Nginx+PHP-FPM. Такой подход экономит время, снижает риски и позволяет быстро повторить окружение для теста или аварийного переезда. Для переездов без простоя пригодится чек-лист из статьи про безостановочную миграцию: перенос сайта без даунтайма.


