OpenTofu : provisionner ses LXC Proxmox comme en production

Un répertoire par LXC, un provider Proxmox communautaire, et un provisioner qui enchaîne automatiquement sur Ansible, voici comment OpenTofu transforme la création d'un conteneur en opération reproductible et versionnée.

Dans l'article précédent, je décrivais l'approche IaC en deux couches. OpenTofu est la première : c'est lui qui crée les conteneurs LXC sur Proxmox avant qu'Ansible ne prenne le relais. Voici comment ça s'articule concrètement.

Pourquoi OpenTofu plutôt que Terraform

OpenTofu est un fork open source de Terraform, né après le changement de licence de HashiCorp en 2023. La syntaxe est identique, les providers sont compatibles, mais la gouvernance est communautaire et la licence reste vraiment libre. Pour un homelab dont l'un des principes est l'indépendance vis-à-vis des éditeurs, le choix s'imposait.

Une structure simple : un répertoire par LXC

Chaque conteneur LXC a son propre répertoire OpenTofu :

opentofu/
├── infra-core/
│   ├── provider.tf
│   ├── main.tf
│   ├── variables.tf
│   └── secret.tfvars   ← gitignored
├── home-automation/
├── media-server/
└── web-server/

Pas de mutualisation forcée, pas de module complexe. Chaque répertoire est autonome et peut être appliqué indépendamment. C'est volontairement simple.

Le provider Proxmox

OpenTofu dialogue avec l'API Proxmox via le provider communautaire bpg/proxmox. C'est aujourd'hui le provider le plus complet et le plus maintenu pour Proxmox, il supporte aussi bien les VM que les conteneurs LXC, la gestion du stockage, des réseaux, et des permissions.

La connexion au cluster se configure une fois dans provider.tf : l'URL de l'API, l'utilisateur, et le mot de passe (ce dernier injecté via variable pour ne jamais apparaître en clair dans le code).

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "0.90.0"
    }
  }
}

provider "proxmox" {
  endpoint = "https://192.168.1.110:8006/"
  api_token = var.proxmox_t4_deploy_token
  insecure = true
}

Le mot de passe est injecté via variable, jamais en clair dans le code.

Ce que décrit main.tf

Le fichier main.tf décrit entièrement le conteneur LXC :

resource "proxmox_virtual_environment_container" "infra_core" {
  node_name = "srv-pve-1"
  vm_id = 100
  unprivileged = true

  operating_system {
    template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
    type             = "debian"
  }

  initialization {
    hostname  = "srv-infra-core"
    dns {
      servers = ["1.168.1.120", "1.1.1.1"]
    }
    ip_config {
      ipv4 {
        address = "192.168.1.120/24" 
        gateway = "192.168.1.1"
      }
    }
    user_account {
      keys = [trimspace(file("~/.ssh/id_rsa.pub"))]
    }
  }

  network_interface {
    name   = "eth0"
    bridge = "vmbr0"
  }

  cpu {
    cores = 2
  }

  memory {
    dedicated = 2048
  }

  disk {
    datastore_id = "local-zfs"
    size         = 10
  }

  features {
    nesting = true
    keyctl  = true
  }

  mount_point {
    volume = "/mnt/public"
    path   = "/mnt/public"
  }

  mount_point {
    volume = "/mnt/backup"
    path   = "/mnt/backup"
  }

  provisioner "local-exec" {
    // ...
}

La feature nesting est indispensable : elle autorise Docker à tourner à l'intérieur du conteneur LXC. Les points de montage NAS sont déclarés directement ici, pas besoin de configuration supplémentaire côté Ansible.

Le vrai intérêt : le provisioner local-exec

C'est là que la chaîne IaC devient intéressante. À la fin du main.tf, un provisioner local-exec s'exécute automatiquement après la création du LXC. Il fait trois choses dans l'ordre :

  1. Attend que le SSH soit disponible sur le nouveau conteneur
  2. Lance le playbook bootstrap.yml pour créer l'utilisateur Ansible et déposer la clé SSH
  3. Lance le playbook complet du service pour configurer tout ce qui doit tourner dessus

Un seul tofu apply crée le LXC et le configure entièrement. Le LXC est prêt à l'emploi à la fin de la commande.

  provisioner "local-exec" {
    command = <<EOT
      # On nettoie l'hôte connu
      ssh-keygen -f '/home/ubuntu/.ssh/known_hosts' -R '192.168.1.120' || true

      # On attend que le SSH réponde vraiment
      echo "Waiting for SSH on 192.168.1.120..."
      until nc -z -v -w5 192.168.1.120 22; do
        echo "SSH not available, waiting 2 seconds..."
        sleep 2
      done

      export ANSIBLE_HOST_KEY_CHECKING=False
      export ANSIBLE_SSH_ARGS="-o ControlMaster=no -o ControlPersist=no -o PreferredAuthentications=publickey"

      ansible-playbook -i ${path.module}/../../ansible/hosts ${path.module}/../../ansible/bootstrap.yml --limit "infra-core" \
        --vault-password-file ~/.vault -u root -e 'ansible_user=root' || exit 1

      ansible-playbook -i ${path.module}/../../ansible/hosts ${path.module}/../../ansible/infra-core.yml \
        --vault-password-file ~/.vault
    EOT
  }

Les secrets : secret.tfvars

Les credentials Proxmox (mot de passe root, tokens API) ne vivent pas dans le code. Ils sont isolés dans un fichier secret.tfvars exclu du dépôt Git. Le Makefile s'assure qu'ils sont toujours passés à la commande via -var-file="secret.tfvars", sans avoir à s'en souvenir.

# variables.tf
variable "proxmox_root_password" {
  description = "Mot de passe de l'utilisateur root@pam"
  type        = string
  sensitive   = true
}

Le Makefile, que nous avons évoqué dans un article précédent, s'assure qu'il est toujours passé via -var-file="secret.tfvars".