Создавать виртуальные машины в Proxmox через веб-интерфейс удобно, пока их одна-две. Но когда нужно поднять стенд из нескольких VM, воспроизвести окружение после переустановки или просто не забыть, как именно была настроена каждая машина, ручная работа быстро становится проблемой.
Terraform позволяет описать инфраструктуру как код: какие VM и контейнеры создать, сколько ресурсов выделить, какой образ использовать, как настроить сеть. После этого terraform apply поднимает всё за минуты, а terraform destroy — убирает без следа.
В этой статье — практический путь от нуля до рабочего стенда: подготовка cloud-init шаблона, создание VM и LXC-контейнеров через Terraform, параметризация для нескольких машин.
Все примеры из статьи собраны в репозитории: khorost-tech/digital-cookbook → proxmox/terraform-vm-automation
В статье
- Зачем Terraform для домашнего Proxmox
- Подготовка: провайдер и доступ к API
- Шаблон VM с cloud-init
- Создание VM из клона шаблона
- Создание VM без клонирования: импорт cloud image
- Создание LXC-контейнера
- Несколько машин: переменные и for_each
- Жизненный цикл: изменение, пересоздание, удаление
- Типичные ошибки
Зачем Terraform для домашнего Proxmox
Terraform в homelab решает три задачи:
- Воспроизводимость. Стенд можно снести и поднять заново за 5 минут. Это особенно ценно для экспериментов: попробовал — удалил — попробовал иначе.
- Документация как код.
.tf-файлы описывают, что есть в кластере. Не нужно помнить, какие VM с какими параметрами создавались полгода назад. - Навык. Terraform — один из стандартных инструментов в production. Практика в homelab переносится напрямую.
(порт 8006)"] B --> C["VM: клон шаблона
или импорт cloud image"] B --> D["LXC-контейнер
из шаблона"] B --> E["Сеть,
диски, DNS"] style A fill:#f9f3e3,stroke:#8b7355 style B fill:#e8dcc8,stroke:#8b7355 style C fill:#c9e4c5,stroke:#5b8a5e style D fill:#c9e4c5,stroke:#5b8a5e style E fill:#c9e4c5,stroke:#5b8a5e
flowchart LR
A["terraform apply"] --> B["Proxmox API
(порт 8006)"]
B --> C["VM: клон шаблона
или импорт cloud image"]
B --> D["LXC-контейнер
из шаблона"]
B --> E["Сеть,
диски, DNS"]
style A fill:#f9f3e3,stroke:#8b7355
style B fill:#e8dcc8,stroke:#8b7355
style C fill:#c9e4c5,stroke:#5b8a5e
style D fill:#c9e4c5,stroke:#5b8a5e
style E fill:#c9e4c5,stroke:#5b8a5e
Подготовка: провайдер и доступ к API
Провайдер bpg/proxmox
Для Proxmox существует несколько Terraform-провайдеров. Актуальный и активно поддерживаемый — bpg/proxmox. Он поддерживает VM, LXC, storage, сети и cloud-init.
terraform {
required_version = ">= 1.6"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = ">= 0.111.0"
}
}
}
provider "proxmox" {
endpoint = var.proxmox_url
api_token = "${var.proxmox_token_id}=${var.proxmox_token_secret}"
insecure = true # для self-signed сертификатов в homelab
ssh {
agent = true
}
}Создание API-токена в Proxmox
API-токен нужен, чтобы Terraform мог управлять ресурсами без пароля root.
# Создаём пользователя для Terraform
pveum user add terraform@pam
# Назначаем роль Administrator на корневой уровень
pveum aclmod / -user terraform@pam -role Administrator
# Создаём API-токен (без привязки к IP)
pveum user token add terraform@pam tf-token --privsep 0
# Вывод: токен вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Сохраните его — повторно посмотреть нельзяФлаг --privsep 0 означает, что токен наследует все права пользователя. Для homelab это допустимо. В production стоит ограничить права ролью с минимальными привилегиями.
Шаблон VM с cloud-init
Cloud-init — стандартный способ инициализации VM при первом запуске: задать hostname, пользователя, SSH-ключ, настроить сеть.
Создать VM в Proxmox через Terraform можно двумя путями:
- Из клона шаблона. Один раз готовим VM-шаблон с cloud image (вручную через
qm), дальше Terraform клонирует его. Этот раздел — про подготовку такого шаблона. - Без клонирования. Terraform сам скачивает cloud image и импортирует его как диск VM — ручной шаблон не нужен, всё описано в коде. Это вариант, который мы используем в своей лаборатории (см. раздел ниже).
Если идёте по второму пути — подготовку шаблона ниже можно пропустить и перейти сразу к импорту образа.
Скачиваем cloud image
# Скачиваем cloud image (выполняется на ноде Proxmox)
cd /var/lib/vz/template/iso/
wget https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img
# Создаём VM, которая станет шаблоном
qm create 9000 --name "ubuntu-cloud-template" --ostype l26 \
--memory 1024 --cores 1 --cpu host \
--net0 virtio,bridge=vmbr0
# Импортируем диск из cloud image
qm importdisk 9000 resolute-server-cloudimg-amd64.img local-lvm
# Подключаем импортированный диск
qm set 9000 --scsihw virtio-scsi-single \
--scsi0 local-lvm:vm-9000-disk-0,ssd=1
# Добавляем cloud-init drive — без него cloud-init не получит конфигурацию
qm set 9000 --ide2 local-lvm:cloudinit
# Настраиваем загрузку с диска
qm set 9000 --boot order=scsi0
# Включаем серийную консоль (для отладки через Proxmox UI)
qm set 9000 --serial0 socket --vga serial0
# Конвертируем VM в шаблон
qm template 9000После этого VM 9000 становится шаблоном. Terraform будет клонировать его для каждой новой VM.
Для Debian процесс аналогичен — меняется только URL образа:
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
qm importdisk 9000 debian-13-genericcloud-amd64.qcow2 local-lvmСоздание VM из клона шаблона
Первый путь — клонировать заранее подготовленный шаблон (раздел выше). Блок clone указывает, какой шаблон копировать; full = true делает независимую полную копию.
Чтобы в одном стенде держать оба способа, в репозитории примеров заведена переменная vm_source (clone или import), а ресурс через for_each включается только под «свой» способ — при неподходящем vm_source карта пустеет, и ресурс не создаётся:
resource "proxmox_virtual_environment_vm" "clone" {
# Активен только при vm_source = "clone"
for_each = var.vm_source == "clone" ? var.vms : {}
node_name = var.target_node
name = each.key
on_boot = true
started = true
# Клонирование из шаблона с cloud-init
clone {
vm_id = var.vm_template_id
full = true
}
cpu {
cores = each.value.cores
type = "host"
}
memory {
dedicated = each.value.memory
}
disk {
interface = "scsi0"
size = each.value.disk
datastore_id = "local-lvm"
}
network_device {
bridge = "vmbr0"
}
# Cloud-init: пользователь, SSH-ключ, сеть
initialization {
user_account {
username = var.default_user
keys = [var.ssh_public_key]
}
ip_config {
ipv4 {
address = each.value.ip
gateway = var.gateway
}
}
dns {
servers = var.dns_servers
}
}
}Запуск
# Скачиваем провайдер
terraform init
# Смотрим, что будет создано
terraform plan
# Создаём VM
terraform apply
# Проверяем доступ
ssh ops@10.10.10.101terraform plan покажет, какие ресурсы будут созданы. terraform apply создаст VM, применит cloud-init, и через 1–2 минуты машина будет доступна по SSH.
Создание VM без клонирования: импорт cloud image
Подход с шаблоном требует ручного шага — собрать шаблон через qm на ноде. Можно обойтись без него: Terraform сам скачивает cloud image и импортирует его как диск VM. Тогда весь жизненный цикл, включая образ ОС, описан в коде — на ноде ничего готовить не нужно. Этот вариант мы используем в лаборатории.
Включить content type «import» на storage
Импорт образа требует, чтобы на storage, куда скачивается образ, был включён тип контента import (по умолчанию выключен):
# Добавляем import к разрешённым типам контента storage local
# (через CLI; то же доступно в UI: Datacenter → Storage → local → Edit → Content)
pvesm set local --content iso,vztmpl,backup,importСкачивание образа и создание VM
Загружает образ ресурс proxmox_download_file, а в диске VM вместо блока clone используется import_from:
# Terraform сам скачивает cloud image на ноду
resource "proxmox_download_file" "cloud_image" {
# Активен только при vm_source = "import"
count = var.vm_source == "import" ? 1 : 0
content_type = "import"
datastore_id = "local"
node_name = var.target_node
url = "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
# Ubuntu .img — это образ формата qcow2; имя с .qcow2, чтобы Proxmox распознал формат
file_name = "resolute-server-cloudimg-amd64.qcow2"
}
resource "proxmox_virtual_environment_vm" "import" {
for_each = var.vm_source == "import" ? var.vms : {}
node_name = var.target_node
name = each.key
on_boot = true
started = true
# Без клонирования: диск импортируется напрямую из скачанного образа
disk {
datastore_id = "local-lvm"
import_from = proxmox_download_file.cloud_image[0].id
interface = "scsi0"
size = each.value.disk # можно задать больше размера образа — диск расширится
}
cpu {
cores = each.value.cores
type = "host"
}
memory {
dedicated = each.value.memory
}
network_device {
bridge = "vmbr0"
}
# Обязателен для Ubuntu/Debian из cloud image: без serial device VM уходит
# в kernel panic при ресайзе boot-диска (в шаблоне для clone он уже есть)
serial_device {
device = "socket"
}
# Cloud-init: тот же блок, что и при клонировании
initialization {
user_account {
username = var.default_user
keys = [var.ssh_public_key]
}
ip_config {
ipv4 {
address = each.value.ip
gateway = var.gateway
}
}
dns {
servers = var.dns_servers
}
}
}Меняется источник диска (import_from вместо clone) и добавляется serial_device — без него Ubuntu/Debian из cloud image уходят в kernel panic при ресайзе boot-диска (в шаблоне для clone serial-устройство уже настроено через qm set --serial0). Остальное — cpu, memory, network_device, initialization — то же, что при клонировании.
Поскольку активен ровно один из ресурсов (.clone или .import), вывод IP объединяет оба через merge — один и тот же output работает при любом vm_source:
output "vm_ips" {
description = "IP-адреса созданных VM"
value = merge(
{
for name, vm in proxmox_virtual_environment_vm.clone :
name => vm.initialization[0].ip_config[0].ipv4[0].address
},
{
for name, vm in proxmox_virtual_environment_vm.import :
name => vm.initialization[0].ip_config[0].ipv4[0].address
},
)
}Какой вариант выбрать
| Клон шаблона | Импорт образа | |
|---|---|---|
| Подготовка | ручной qm-шаблон один раз |
всё в Terraform, на ноде ничего |
Скорость apply |
быстрее (копия готового диска) | дольше (скачивание + импорт образа) |
| Версия ОС | зашита в шаблоне | в url образа, видна в коде |
| Доп. требования | — | content type import на storage |
| Когда удобнее | много VM из одного базиса, частые пересоздания | воспроизводимость «с нуля», образ под контролем версий |
На практике клонирование выигрывает, когда стенд часто поднимается из одного и того же базового образа: клон готового диска быстрее. Импорт удобнее, когда важно, чтобы вообще ничего не было настроено руками на ноде, а версия образа жила в репозитории рядом с кодом.
Создание LXC-контейнера
LXC-контейнеры легче VM: быстрее стартуют, потребляют меньше ресурсов. Подходят для сервисов, которым не нужно кастомное ядро.
resource "proxmox_virtual_environment_container" "lab" {
for_each = var.containers
node_name = var.target_node
initialization {
hostname = each.key
ip_config {
ipv4 {
address = each.value.ip
gateway = var.gateway
}
}
user_account {
keys = [var.ssh_public_key]
}
dns {
servers = var.dns_servers
}
}
cpu {
cores = each.value.cores
}
memory {
dedicated = each.value.memory
}
disk {
datastore_id = "local-lvm"
size = each.value.disk
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = each.value.template
type = "debian"
}
features {
nesting = true # для Docker внутри LXC
}
started = true
}Для LXC используются системные шаблоны, а не cloud image. Скачать их можно через UI Proxmox (local → CT Templates → Templates) или через pveam:
# Обновляем список доступных шаблонов
pveam update
# Скачиваем Debian 13
# Точное имя свежего шаблона: pveam available --section system | grep debian-13
pveam download local debian-13-standard_13.1-2_amd64.tar.zstНесколько машин: переменные и for_each
Ключевой приём — описать все машины как map в variables.tf, а в ресурсе использовать for_each. Это позволяет добавлять и удалять VM простым изменением конфигурации.
# Способ создания VM: "import" (без шаблона) или "clone" (из шаблона)
vm_source = "import"
vms = {
"docker-host" = {
cores = 4
memory = 4096
disk = 40
ip = "10.10.10.101/24"
}
"k3s-node" = {
cores = 2
memory = 2048
disk = 20
ip = "10.10.10.102/24"
}
"monitoring" = {
cores = 2
memory = 2048
disk = 30
ip = "10.10.10.103/24"
}
}
containers = {
"dns-server" = {
cores = 1
memory = 256
disk = 4
ip = "10.10.10.201/24"
template = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
}
}Добавить новую VM — одна запись в map. Удалить — убрать запись и terraform apply. Terraform сам определит, что изменилось.
Жизненный цикл: изменение, пересоздание, удаление
Изменение параметров
Некоторые параметры VM можно менять на месте (память, количество ядер). Другие (размер диска, шаблон) требуют пересоздания. Terraform покажет это в plan:
# Увеличили память в tfvars
terraform plan
# ~ proxmox_virtual_environment_vm.import["k3s-node"] будет обновлена на месте
terraform apply
# Изменили шаблон — VM будет пересоздана
terraform plan
# -/+ proxmox_virtual_environment_vm.import["k3s-node"] будет замененаУдаление стенда
# Удаляем все созданные ресурсы
terraform destroy
# Удаляем одну конкретную VM
terraform destroy -target='proxmox_virtual_environment_vm.import["k3s-node"]'State-файл
Terraform хранит текущее состояние в terraform.tfstate. Для homelab достаточно локального файла, но его стоит добавить в .gitignore (содержит IP, токены и внутренние ID). Для командной работы — remote state в S3 или Consul.
Типичные ошибки
1. Забыть cloud-init drive
Без --ide2 local-lvm:cloudinit при создании шаблона cloud-init не получит конфигурацию. VM запустится, но SSH-ключ, hostname и сеть не применятся. Симптом: машина загружается, но недоступна по сети.
2. Недостаточные права API-токена
Если токен создан с --privsep 1 (по умолчанию), у него собственный набор прав. Для управления VM нужны как минимум: VM.Allocate, VM.Config.*, VM.Monitor, Datastore.AllocateSpace, SDN.Use. Проще всего для homelab — --privsep 0.
3. Конфликт IP-адресов
В for_each легко скопировать запись в tfvars и забыть сменить ip — две VM получат один адрес. Для Terraform это валидно, он создаст обе, а конфликт всплывёт уже в сети. Проверяйте уникальность адресов в terraform.tfvars. MAC-адреса Proxmox при этом генерирует уникальные сам, независимо от full/linked clone: полное и связанное клонирование различаются только диском, не сетью.
4. Уменьшение диска
Proxmox не поддерживает уменьшение дисков. Если в terraform.tfvars уменьшить значение disk, apply упадёт с ошибкой. Решение — lifecycle { ignore_changes = [disk[0].size] } или пересоздание VM.
5. Drift: ручные изменения в Proxmox UI
Если изменить VM через веб-интерфейс, state Terraform рассинхронизируется. terraform plan покажет расхождение. Решение: либо не трогать руками то, что управляется Terraform, либо terraform import для синхронизации.
6. Импорт образа без включённого content type «import»
При создании VM без клонирования (import_from) apply падает, если на целевом storage не разрешён тип контента import — он выключен по умолчанию. Симптом: ошибка про недопустимый content type или образ не скачивается. Решение — включить import на storage (pvesm set local --content ...,import или через UI).
Итог
Terraform + Proxmox — это не production-grade IaC, но для homelab он закрывает главную задачу: стенд из нескольких VM и контейнеров описывается в нескольких файлах и поднимается за минуты.
Минимальный рабочий набор:
- Источник образа: либо cloud-init шаблон (один раз, для клонирования), либо
import_fromс загрузкой образа прямо в Terraform (без шаблона) provider.tf+variables.tf+vm.tf(илиct.tf)terraform init && terraform apply
Клонирование быстрее, когда стенд часто пересоздаётся из одного базиса; импорт образа удобнее, когда хочется держать всё в коде и ничего не настраивать на ноде руками.
Дальше можно добавлять: модули для разных типов стендов, remote state, интеграцию с Ansible для provisioning после создания VM. Но начинать стоит с простого — одного источника образа и пары файлов.
Комментарии