Build AlmaLinux 9 VMs on KVM/libvirt with Terraform. One base image is fetched once and shared; every VM is a copy-on-write overlay on it, configured through cloud-init.
vms = {
"alma9-01" = { ip = "192.168.122.240/24" }
"alma9-02" = { memory = 8192, vcpu = 4, disk_size = 60, extra_disks = [100] }
"alma9-03" = {} # no ip = DHCP
}terraform init && terraform apply
| Path | What |
|---|---|
provider.tf |
provider and version constraints |
variables.tf |
every input, typed and described, no defaults |
terraform.tfvars |
every value — the only file you normally edit |
main.tf |
the shared base image, then one module.vm per entry in vms |
outputs.tf |
names, UUIDs, MACs, addresses, ready-made ssh and console commands |
modules/vm/ |
one VM: disks, cloud-init, the domain |
modules/vm/templates/ |
cloud-init user-data and network-config |
Variables carry no defaults on purpose: a value you forgot fails at plan time instead of quietly taking one that was buried in the code.
- Any number of VMs from one map, each able to override memory, CPUs, disk size, address, network, extra disks and autostart.
- Static addressing or DHCP per VM, through cloud-init network-config v2
rather than hand-written
ifcfgfiles, and converted to NetworkManager keyfiles on first boot. - Copy-on-write disks. The base image is stored once. A 30 GiB VM disk occupies about 200 KiB until the guest writes to it.
- The image is fetched for you from a URL — nothing has to be downloaded onto the hypervisor first.
- SSH hardening following the ssh-audit RHEL8 hardening guide: host keys regenerated (the image ships the same ones to everybody), weak moduli pruned, the ECDSA host key dropped, and ciphers, MACs, key exchange and signature algorithms restricted.
- Serial console wired up, so
virsh console <name>shows the whole boot. - UEFI, Secure Boot, TPM 2.0, SPICE/VNC/headless, extra disks, CPU mode and machine type are all switches.
- Graceful shutdown on destroy — an ACPI shutdown with a timeout, instead of pulling the plug.
Tested end to end on this hypervisor, same configuration, only image_url,
image_name and image_capacity changed:
| Release | Result |
|---|---|
| 9.8 | static IP, NetworkManager keyfile after nmcli connection migrate, ECDSA host key removed, ipv6 off, SELinux enforcing |
| 10.2 | same, and cloud-init picks the network-manager renderer itself, so the keyfile is written directly |
/etc/sysconfig/network-scripts does not exist on 10.x, which is why the
configuration describes the network once in cloud-init network-config v2
rather than writing ifcfg files. 10.x also needs an x86-64-v3 host CPU:
/usr/lib64/ld-linux-x86-64.so.2 --help | grep x86-64-v3
| Terraform | >= 1.16 (tested on 1.16.5) |
| Provider | dmacvicar/libvirt ~> 0.9.9 |
| Hypervisor | libvirt with a storage pool and a network; swtpm only if tpm = true, OVMF only if firmware = "efi" |
Version 0.9.0 of the libvirt provider is a redesign, not a new release: HCL now
maps almost one-to-one onto libvirt XML, and
the author states
there is no automated way to migrate the HCL. This repo was rewritten against
the new schema. If you are pinned to the old one, the legacy provider lives on
in the project's v0.8 branch.
State does not carry over. Destroy with the old code, then apply with this.
image_capacityhas to be set because the image URL redirects to mirrors that do not always sendContent-Length, and the upload then fails. Read it withqemu-img info <file> | grep 'virtual size'.image_urltakes a local path or afile://URL as happily ashttps://. Worth doing if your nearest mirror is slow: measured from one host, the AlmaLinux image pulled at ~14 MB/s and the Rocky one at ~2-3 MB/s. The image is fetched once and shared, so this is a per-image cost, not per VM.selinux_mode = "disabled"and anygrub_*option need one reboot to take effect;reboot_after_first_bootdoes that at the end of first boot.crashkernel=auto, which earlier versions of this repo set, is not carried over: EL9 ignores it.- Changing cloud-init settings does not reconfigure a running VM. cloud-init runs once per instance id, so the VM has to be rebuilt.
Apache 2.0. See LICENSE.
Full input and output reference, generated by terraform-docs:
| Name | Version |
|---|---|
| terraform | >= 1.16.0 |
| libvirt | ~> 0.9.9 |
| Name | Version |
|---|---|
| libvirt | ~> 0.9.9 |
| Name | Description | Type | Required |
|---|---|---|---|
| console_show_ip | Print the guest's IPv4 address at the login prompt, by writing /etc/issue and /etc/issue.net with agetty's \4{iface} escape. The Rocky repo did this; the AlmaLinux one did not. |
bool |
yes |
| cpu_mode | libvirt CPU mode. host-passthrough blocks live migration between unlike hosts. | string |
yes |
| default_disk_size | Root disk in GiB for any VM that does not set its own. | number |
yes |
| default_gateway | Default route for a VM that sets ip without its own gateway. Null writes none. |
string |
yes |
| default_memory | Guest memory in MiB for any VM that does not set its own. | number |
yes |
| default_network | libvirt network for any VM that does not name one. | string |
yes |
| default_vcpu | Virtual CPUs for any VM that does not set its own. | number |
yes |
| firmware | bios or efi. efi needs OVMF on the hypervisor. | string |
yes |
| graphics | spice, vnc or none. none leaves only the serial console. | string |
yes |
| grub_cmdline_extra | Extra kernel command-line arguments, appended to whatever the serial and ipv6 options add. Note crashkernel=auto from the old repo is obsolete:EL9 ignores it. |
list(string) |
yes |
| grub_serial_console | Put the boot loader and the kernel on the serial port as well as the console, so virsh console shows the whole boot. This is what the oldrepo's GRUB_TERMINAL / GRUB_SERIAL_COMMAND lines did. |
bool |
yes |
| harden_ssh | Apply the ssh-audit hardening (hardening_guides.html#rhel8) and regenerate the host keys the image shipped with. | bool |
yes |
| image_capacity | Virtual size of the base image, in bytes. Normally the provider reads this from the upload's Content-Length, but repo.almalinux.org redirects to mirrors that do not send one, and the upload then fails with "Volume capacity is required when the upload source does not provide Content-Length". Read it off an image with: qemu-img info | grep 'virtual size' |
number |
yes |
| image_name | Volume name for the shared base image inside the pool. | string |
yes |
| image_url | Where to fetch the AlmaLinux cloud image from. The provider uploads it into the pool, so an https:// URL or a local path both work. Fetched once; every VM gets a thin overlay on top of it. |
string |
yes |
| include_default_user | Keep the cloud image's own user (almalinux, rocky) alongside the ones inusers. The original did this with a bare - default; dropping it removesthe documented fallback login. |
bool |
yes |
| ipv6_disable | Add ipv6.disable=1 to the kernel command line, as the old repo did. | bool |
yes |
| libvirt_uri | libvirt connection URI. qemu+ssh://user@host/system for a remote hypervisor. | string |
yes |
| machine_type | QEMU machine type. q35 is current; pc is legacy i440fx. | string |
yes |
| nameservers | DNS resolvers written into guests that use a static address. | list(string) |
yes |
| nm_keyfiles | Convert the network config cloud-init renders into NetworkManager keyfiles on first boot, with nmcli connection migrate.On EL9, cloud-init selects the sysconfig renderer and writes/etc/sysconfig/network-scripts/ifcfg-eth0 - the same legacy file this repo used to hand-write. The renderer itself cannot be changed from user-data, because it is chosen in the init-local stage before user-data merges, so the file is converted after the fact instead. Verified on AlmaLinux 9.8 with NetworkManager 1.54: the ifcfg file is removed and the address is kept. A no-op on EL10, which has no network-scripts to begin with. |
bool |
yes |
| package_upgrade | Run a full package upgrade on first boot. Adds minutes. | bool |
yes |
| packages | Packages cloud-init installs on first boot. | list(string) |
yes |
| permit_root_ssh | Whether sshd accepts root logins. Keys only; password auth stays off either way. | bool |
yes |
| pool | Storage pool for the base image, the VM disks and the cloud-init ISOs. | string |
yes |
| primary_interface | Interface name agetty reads the address from, for the issue files. | string |
yes |
| qemu_agent | Attach the guest agent channel. Required for the ip_addresses output. | bool |
yes |
| rc_local_every_boot | Install the original's /etc/rc.d/rc.local, which runs on EVERY boot and re-runs grub2-mkconfig, a full package upgrade, and the ssh host key regeneration. Note the consequence: the host keys change on every reboot, so known_hosts breaks each time. Off by default because that reads as a mistake in the original rather than an intent. |
bool |
yes |
| reboot_after_first_boot | Reboot once at the end of first boot. The old repo always did this, from its run-once rc.local script, because grub and SELinux changes need it. Not needed when selinux_mode is enforcing and no grub option is set. |
bool |
yes |
| root_password_hash | SHA-512 crypt hash for root. Null leaves root locked, as the cloud image ships it. | string |
yes |
| root_ssh_authorized_keys | Keys for root. Only reachable when permit_root_ssh is true. | list(string) |
yes |
| runcmd | Extra shell commands for the end of first boot. | list(string) |
yes |
| search_domains | DNS search domains written into guests that use a static address. | list(string) |
yes |
| selinux_mode | enforcing, permissive or disabled. Anything but enforcing costs one extra reboot. | string |
yes |
| shutdown_timeout | Seconds to wait for an ACPI shutdown on destroy before forcing it off. | number |
yes |
| ssh_authorized_keys | Keys added to every user, on top of that user's own list. | list(string) |
yes |
| ssh_password_auth | Allow password logins over ssh. The original set this true; it defaults to false here, which is the one security default deliberately changed from the original. Key-only login is unaffected either way. |
bool |
yes |
| timezone | Guest timezone, IANA name. | string |
yes |
| tpm | Attach an emulated TPM 2.0. Needs swtpm on the hypervisor. | bool |
yes |
| users | Logins to create. sudo passwordless sudo (default true) shell (default /bin/bash) groups (default ["adm","wheel"]) password_hash SHA-512 crypt, from mkpasswd -m sha-512.Null locks the password, leaving key-only login. |
list(object({ |
yes |
| vms | The VMs to create, keyed by name. The key becomes the domain name and the guest hostname. Anything left out of an entry falls back to the matching default_* value. |
map(object({ |
yes |
| Name | Description |
|---|---|
| console | Attach to a guest's serial console. |
| ip_addresses | IPv4 addresses libvirt reports per VM, from the DHCP leases or the guest agent. Empty until the guest is up, so a fresh apply may need one terraform refresh before these fill in. |
| ssh | Ready-to-paste ssh commands. DHCP guests resolve through ip_addresses instead. |
| vms | Name, UUID, MAC, configured address and root disk path for each VM. |