Proxmox + Terraform: автоматизируем создание VM и контейнеров в домашней лаборатории

Как использовать Terraform для автоматического создания и управления виртуальными машинами и LXC-контейнерами в Proxmox VE: провайдер bpg/proxmox, cloud-init шаблоны, примеры VM и CT, типичные ошибки

Создавать виртуальные машины в Proxmox через веб-интерфейс удобно, пока их одна-две. Но когда нужно поднять стенд из нескольких VM, воспроизвести окружение после переустановки или просто не забыть, как именно была настроена каждая машина, ручная работа быстро становится проблемой.

Terraform позволяет описать инфраструктуру как код: какие VM и контейнеры создать, сколько ресурсов выделить, какой образ использовать, как настроить сеть. После этого terraform apply поднимает всё за минуты, а terraform destroy — убирает без следа.

В этой статье — практический путь от нуля до рабочего стенда: подготовка cloud-init шаблона, создание VM и LXC-контейнеров через Terraform, параметризация для нескольких машин.

Все примеры из статьи собраны в репозитории: khorost-tech/digital-cookbook → proxmox/terraform-vm-automation

В статье

Зачем Terraform для домашнего Proxmox

Terraform в homelab решает три задачи:

  • Воспроизводимость. Стенд можно снести и поднять заново за 5 минут. Это особенно ценно для экспериментов: попробовал — удалил — попробовал иначе.
  • Документация как код. .tf-файлы описывают, что есть в кластере. Не нужно помнить, какие VM с какими параметрами создавались полгода назад.
  • Навык. Terraform — один из стандартных инструментов в production. Практика в homelab переносится напрямую.
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

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
Terraform управляет ресурсами Proxmox через API

Подготовка: провайдер и доступ к 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.101

terraform 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 и контейнеров описывается в нескольких файлах и поднимается за минуты.

Минимальный рабочий набор:

  1. Источник образа: либо cloud-init шаблон (один раз, для клонирования), либо import_from с загрузкой образа прямо в Terraform (без шаблона)
  2. provider.tf + variables.tf + vm.tf (или ct.tf)
  3. terraform init && terraform apply

Клонирование быстрее, когда стенд часто пересоздаётся из одного базиса; импорт образа удобнее, когда хочется держать всё в коде и ничего не настраивать на ноде руками.

Дальше можно добавлять: модули для разных типов стендов, remote state, интеграцию с Ansible для provisioning после создания VM. Но начинать стоит с простого — одного источника образа и пары файлов.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии