ZIM-НИЙ SAAALEЗимние скидки: до −50% на старт и −20% на продление
до 31.01.2026 Подробнее
Выберите продукт

IaC для малого проекта: Terraform + Ansible для поднятия LEMP на VDS

Малому проекту тоже нужна автоматизация: меньше ручных ошибок, предсказуемые релизы, быстрая реконфигурация. В статье свяжем Terraform и Ansible, чтобы развернуть LEMP на одном VDS: от структуры репозитория и переменных до плейбуков, шаблонов, отладки и безопасного запуска.
IaC для малого проекта: Terraform + Ansible для поднятия LEMP на VDS

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 и Ansible

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.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

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 для LEMP

Пароли и приватные данные держите в зашифрованном виде с 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;
}

Порядок запуска

  1. Сгенерируйте или укажите SSH-ключ. Проверьте, что публичный ключ указан в переменных Terraform и cloud-init.
  2. Заполните terraform.tfvars и cloud-init.yml под свои нужды.
  3. Выполните Terraform: инициализация, план, применить.
  4. Подставьте публичный IP в inventory.ini, либо сформируйте его автоматически из output.
  5. Запустите 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, буферы, кэш статики, ограничение скорости запросов.
FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой SSL бесплатно!

Типичные ошибки и отладка

  • Не сходится 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. Такой подход экономит время, снижает риски и позволяет быстро повторить окружение для теста или аварийного переезда. Для переездов без простоя пригодится чек-лист из статьи про безостановочную миграцию: перенос сайта без даунтайма.

Поделиться статьей

Вам будет интересно

Nginx 444 и 403: блокируем ботов и сканеры через map, geo, deny и return OpenAI Статья написана AI (GPT 5)

Nginx 444 и 403: блокируем ботов и сканеры через map, geo, deny и return

Разбираем, когда выбирать nginx 444, а когда 403, и как собрать аккуратную схему блокировок на map и geo. Покажу deny и return 444 ...
Kubernetes PV/PVC: Pending, FailedMount, ReadOnly и застрявшие finalizers — практический разбор OpenAI Статья написана AI (GPT 5)

Kubernetes PV/PVC: Pending, FailedMount, ReadOnly и застрявшие finalizers — практический разбор

Если PVC завис в Pending, поды получают FailedMount, том внезапно становится ReadOnly или удаление PV/PVC висит на finalizers — пр ...
SSH Permission denied (publickey): проверяем sshd_config и ~/.ssh без лишней боли OpenAI Статья написана AI (GPT 5)

SSH Permission denied (publickey): проверяем sshd_config и ~/.ssh без лишней боли

Permission denied (publickey) обычно сводится к трём причинам: клиент отправляет не тот ключ, сервер не принимает ключевую аутенти ...