Terraform-модуль VPN-сервера: автоматизация
Сервер для VPN живёт не так спокойно, как обычный веб-хост: IP блокируют, регион приходится менять, а после инцидента нужно поднять замену за минуты, а не за час ручной настройки. Если каждый раз повторять одни и те же шаги — создать VPS, поставить WireGuard, сгенерировать ключи, открыть порт — рано или поздно что-то забудется. Решение — оформить весь процесс в Terraform-модуль: один вызов module, и на выходе рабочий VPN-сервер с предсказуемой конфигурацией.
Содержание
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Зачем VPN-серверу отдельный Terraform-модуль
Обычный VPS можно один раз настроить и годами не трогать. VPN-сервер — другая история: узлы блокируют по IP, их приходится пересоздавать в другом регионе или дата-центре, добавлять резервные ноды, синхронизировать конфигурацию между несколькими точками (например, RU/US/UK). Каждое из этих действий — не разовая задача, а повторяющийся сценарий, и именно повторение оправдывает инфраструктуру как код.
Модуль отличается от обычного .tf-файла тем, что инкапсулирует логику: снаружи вы задаёте несколько переменных (регион, протокол, размер сервера), а внутри модуля скрыты детали — какой образ ставить, как генерировать cloud-init, какие правила firewall открывать. Если понадобится третий сервер в новом регионе — это module "vpn_de" { source = "./modules/vpn-server", region = "de1" }, а не копирование полусотни строк конфига с риском забыть одну настройку.
Отдельно стоит подчеркнуть: модуль в этом контексте отвечает только за инфраструктурный слой — сервер, диск, сеть, firewall-правила и bootstrap VPN-демона через cloud-init. Он не заменяет ручную установку WireGuard как процесс — он автоматизирует ровно те же самые шаги, которые вы делали бы руками, просто один раз описанные и переиспользуемые.
Структура модуля: файлы и переменные
Минимальный модуль VPN-сервера — это отдельная директория со своим набором файлов, независимая от корневой конфигурации проекта:
modules/
vpn-server/
main.tf # ресурс сервера, диск, сетевые правила
variables.tf # входные параметры модуля
outputs.tf # что модуль возвращает наружу
cloud-init.tftpl # шаблон bootstrap-скрипта
Входные переменные стоит проектировать так, чтобы модуль оставался протокол-агностичным — сегодня это WireGuard, завтра может понадобиться OpenVPN на том же каркасе:
# modules/vpn-server/variables.tf
variable "name" {
type = string
}
variable "region" {
type = string
}
variable "size" {
type = string
default = "1vcpu-2gb"
}
variable "vpn_protocol" {
type = string
default = "wireguard"
validation {
condition = contains(["wireguard", "openvpn"], var.vpn_protocol)
error_message = "Поддерживается только wireguard или openvpn."
}
}
variable "client_count" {
type = number
default = 5
}
variable "ssh_key_id" {
type = string
}
А main.tf модуля собирает ресурс сервера и подключает bootstrap через user_data — именно здесь модуль отличается от статьи про голые основы Terraform: сервер не просто создаётся, а сразу выходит из apply с работающим VPN:
# modules/vpn-server/main.tf
resource "cloud_server" "vpn" {
name = var.name
image = "ubuntu-24-04"
size = var.size
region = var.region
ssh_keys = [var.ssh_key_id]
user_data = templatefile("${path.module}/cloud-init.tftpl", {
protocol = var.vpn_protocol
client_count = var.client_count
})
tags = ["vpn", var.vpn_protocol, var.region]
}
resource "cloud_firewall_rule" "vpn_udp" {
server_id = cloud_server.vpn.id
protocol = "udp"
port = var.vpn_protocol == "wireguard" ? 51820 : 1194
source = "0.0.0.0/0"
}
Провайдер и имена ресурсов здесь условные — у DigitalOcean, Hetzner, Yandex Cloud или другого провайдера конкретные поля будут отличаться, но общая логика модуля (сервер + firewall-правило + cloud-init) переносится один в один.
Арендуйте сервер под свои задачи!
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверУстановка VPN через cloud-init внутри модуля
Ключевая часть модуля — не сам сервер, а bootstrap-скрипт, который превращает чистый Ubuntu в готовый VPN-узел ещё до того, как вы туда зашли по SSH. cloud-init.tftpl — это шаблон, который Terraform рендерит переменными модуля и передаёт как user_data:
# modules/vpn-server/cloud-init.tftpl
#cloud-config
package_update: true
packages:
- wireguard
- qrencode
runcmd:
- echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
- sysctl -p
- wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
- chmod 600 /etc/wireguard/server_private.key
- |
cat > /etc/wireguard/wg0.conf <<EOF
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/server_private.key)
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
EOF
- systemctl enable wg-quick@wg0
- systemctl start wg-quick@wg0
Это тот же набор команд, который вы бы выполнили руками при обычной установке — просто он выполняется автоматически при первом старте сервера, cloud-init это часть стандартного образа большинства провайдеров и не требует ssh-доступа для запуска. Через 30–60 секунд после terraform apply сервер уже поднял интерфейс wg0 и готов принимать клиентов — остаётся добавить пиров.
Для OpenVPN логика та же, только вместо wg genkey в runcmd идёт разворачивание easy-rsa и генерация сертификатов — сам шаблон .tftpl можно параметризовать через if var.protocol == "wireguard" внутри Terraform-функции templatefile, но на практике проще держать два отдельных шаблона и выбирать нужный условием в main.tf — читается понятнее, чем ветвление внутри YAML.
Генерация ключей и хранение секретов
Здесь стоит быть честным: у Terraform нет встроенного провайдера для генерации ключей WireGuard (в отличие, например, от tls_private_key для классических TLS-сертификатов), и это осознанное ограничение экосистемы, а не недоработка модуля. Есть два рабочих подхода, и у каждого свои компромиссы.
Подход А — ключи генерируются на сервере (рекомендуется). Именно так устроен пример выше: приватный ключ никогда не покидает сервер и не попадает в state-файл Terraform. Публичный ключ можно забрать после создания через remote-exec provisioner с SSH-подключением:
resource "null_resource" "fetch_pubkey" {
depends_on = [cloud_server.vpn]
connection {
type = "ssh"
host = cloud_server.vpn.ip_address
user = "root"
}
provisioner "remote-exec" {
inline = ["cat /etc/wireguard/server_public.key > /tmp/pubkey"]
}
}
Публичный ключ не секрет — его можно спокойно выводить в terraform output и передавать клиентам.
Подход Б — ключи генерируются локально и передаются через cloud-init. Быстрее с точки зрения автоматизации сборки клиентских конфигов сразу после apply (не нужен отдельный шаг за ключом), но приватный ключ в этом случае оседает в terraform.tfstate в открытом виде, даже если переменная помечена sensitive = true — эта пометка скрывает значение только в CLI-выводе, а не в самом файле state. Если выбираете этот путь — remote backend с шифрованием и ограниченным доступом обязателен, а не опционален.
Для одиночного тестового сервера разница некритична. Для боевой инфраструктуры — подход А безопаснее и снимает вопрос «что если кто-то получит доступ к state».
Мульти-регион: один модуль, несколько серверов
Модуль раскрывает себя, когда серверов больше одного. Вместо трёх копий одинакового кода — три вызова модуля с разными параметрами, например по локациям, из которых физически работает инфраструктура:
locals {
vpn_nodes = {
ru = { region = "ru-central1", size = "1vcpu-2gb" }
us = { region = "us-east1", size = "1vcpu-2gb" }
uk = { region = "eu-west2", size = "1vcpu-2gb" }
}
}
module "vpn" {
source = "./modules/vpn-server"
for_each = local.vpn_nodes
name = "vpn-${each.key}"
region = each.value.region
size = each.value.size
vpn_protocol = "wireguard"
ssh_key_id = var.ssh_key_id
}
output "vpn_ips" {
value = { for k, m in module.vpn : k => m.server_ip }
}
for_each по карте регионов — практичнее, чем count: при удалении одной ноды (скажем, uk заблокировали и переносите её в другой дата-центр) Terraform не начнёт пересчитывать индексы и пересоздавать соседние серверы, как случилось бы с count. Каждый узел из карты — самостоятельный ресурс с собственным адресом состояния.
Дальше к таким узлам логично добавить мониторинг доступности — он не входит в сам модуль (это отдельный слой ответственности), но именно multi-region раскладка из Terraform даёт список IP, которые сразу можно скормить в конфиг проверок.
Обновление, пересоздание и откат
Не все изменения в модуле проходят без последствий, и это важно понимать перед apply, а не после. Смена region или image у ресурса сервера в большинстве провайдеров помечается в terraform plan как -/+ — пересоздание с удалением старого сервера и подъёмом нового. Для VPN это означает новый IP и новый публичный ключ — клиентские конфиги придётся переслать заново.
Если нужно поменять что-то не влияющее на идентичность сервера (например, размер size на большинстве провайдеров можно изменить in-place, без пересоздания) — terraform plan покажет это как ~ update, без разрушения. Разницу между ~ и -/+ в выводе плана стоит проверять каждый раз, особенно в модуле, который вызывается сразу для нескольких узлов — одна опечатка в переменной может привести к синхронному пересозданию всех VPN-серверов одновременно.
Для управляемого обновления полезны две вещи:
lifecycle { create_before_destroy = true }в ресурсе сервера — новый узел поднимается до того, как выключится старый, минимизируя простой при плановой замене.terraform state mv— если вы переименовали модуль или ресурс внутри него (например, при рефакторингеmain.tf), это позволяет сохранить связь с уже существующим сервером в облаке, не пересоздавая его физически, а просто обновив адрес в state.
Откат — это применение предыдущей версии кода модуля из git-истории. Если модуль версионируется (например, через git-тег или отдельный релиз в приватном реестре модулей), откат — это terraform apply с указанием прежней версии source, а не ручное вспоминание, что было раньше.
Арендуйте сервер под свои задачи!
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Модуль подходит для одного личного VPN-сервера?
Формально да, но выгода минимальна — для одного сервера проще один раз настроить его руками или простым shell-скриптом. Модуль окупается, когда серверов несколько или их приходится регулярно пересоздавать.
Что произойдёт с существующими клиентами, если сервер пересоздастся?
Если ключи генерируются на сервере (подход А из статьи), новый сервер получит новые ключи, и все клиентские конфиги устареют. Чтобы этого избежать при плановых изменениях, не влияющих на VPN-логику, следите, чтобы plan показывал ~, а не -/+.
Можно ли использовать один модуль для WireGuard и OpenVPN одновременно?
Да, через переменную vpn_protocol и условную логику в cloud-init, как показано выше. На практике проще держать два отдельных шаблона cloud-init и выбирать нужный по условию в main.tf, чем городить ветвления внутри одного YAML-файла.
Нужен ли Ansible, если уже есть модуль с cloud-init?
Для первичного bootstrap — не обязательно, cloud-init справляется с установкой пакета и запуском сервиса. Ansible пригождается для более сложных сценариев: регулярного обновления конфигурации пиров, ротации ключей по расписанию, синхронизации правил между несколькими уже работающими серверами.
Где хранить сам код модуля, если серверов и проектов несколько?
Для начала достаточно локальной директории modules/ рядом с корневой конфигурацией. Когда модуль используют несколько независимых проектов, его выносят в отдельный git-репозиторий и подключают через source = "git::https://..." с указанием тега версии.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →