Introduction: from theory to practice#
In the first article on Flatcar Container Linux, the theoretical foundations of this immutable operating system were laid out: a read-only /usr directory, an A/B partitioning scheme enabling atomic updates with automatic failover in the event of failure, and deterministic provisioning handled by Ignition right from the initramfs.
Theory is all well and good, but practice is even better! The aim of this second part is to deploy an initial Flatcar Container Linux virtual machine on Proxmox VE in a fully declarative manner using OpenTofu.
Rather than writing a Butane file by hand, there is the community provider community-terraform-providers/ignition, combined with the bpg/proxmox provider, to deploy a virtual machine. This allows you to generate the Ignition configuration directly in HCL, making use of variables and templating functions, all within my favourite infrastructure-as-code language!
To make this exercise as practical as possible, I propose deploying the Blocky DNS service, shipped with great features that include blocking adverts and unwanted domains using hosts files that you define. This service is based on a container and a YAML file for configuration, making it an ideal candidate for this scenario.
Prerequisites and architecture#
Before writing the first lines of code, here is what you will need to carry out this deployment:
- A working Proxmox VE node (8.4 or later) – that goes without saying;
- The
tofubinary installed (version 1.10 or later); - An SSH key pair loaded into your local SSH agent, specifically to connect to the Proxmox hypervisor and inject the Ignition configuration into the Datastore;
- A free IP address on your local network to be assigned to the DNS server;
- A Proxmox user and API token with
Sys.AuditandSys.Modifypermissions on the/path; these privileges are required by the endpoint used to download the Flatcar image; - The
importcontent type enabled on the datastore that will receive the Flatcar image.
The last two points are the easiest to overlook and will result in HTTP 403 and 500 errors on the first tofu apply command. The corresponding pveum and pvesm commands are detailed in the README.md file of the repository presented below.
One important point to note before going any further: from the Flatcar project’s perspective, Proxmox VE is one of the platforms supported by the community. This means that the dedicated image is indeed produced with every release, but it does not undergo the Flatcar team’s official suite of automated tests.
In practice, this works very well, but it’s best to be aware of this before rushing headlong into production.
Project structure#
All the code covered in this article is available in this GitHub repository:
To ensure a clear and maintainable structure, responsibilities are split across several files:
├── README.md
├── backend.tf
├── encryption.tf
├── files/
│ └── blocky.yml
├── ignition.tf
├── image.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars.example
└── variables.tfThe state encryption configuration can be found in encryption.tf, the backend declaration in backend.tf, the version and provider declarations in providers.tf, the download of the Flatcar image in image.tf, all the configuration generation in ignition.tf, and finally the instantiation of Ignition via a snippet and of the virtual machine in main.tf.
All default values can be found in terraform.tfvars.example. Of course, you’re free to adjust these values!
Configure the providers and add a layer of security#
Let’s get straight to the point with the providers.tf file, where the version of the ignition provider is deliberately pinned at ~> 2.6.0 rather than the latest 2.7.x branch. I’ll come back to this in the section on Ignition, but it relates to the version of the JSON specification generated by the provider.
The ignition provider requires no additional parameters, unlike the proxmox provider, where the proxmox_api_token variable must be passed as an environment variable via a .envrc file to make life easier:
export TF_VAR_proxmox_api_token="tofu@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"In addition to this, the encryption.tf file adds encryption not only to the state but also to the plan.
In fact, when using Ignition with OpenTofu, the entire configuration rendered as JSON is stored in plain text in the state file or tfstate. Even though the only thing injected here is an SSH public key, it is very common to include passwords, TLS certificates or even tokens…
With OpenTofu, and this is why I much prefer it to Terraform, this feature is included by default.
Finally, even when using a local backend for this type of project in Lab mode, the .tfstate file is fully encrypted and can even be stored in a private Git repository without any risk of secrets being leaked. In a production environment, however, I would still recommend combining this encryption with a remote backend, particularly for concurrency concerns.
The Flatcar image#
Now that the providers are ready, let’s retrieve the Flatcar Container Linux image designed for Proxmox VE. There’s no need to connect to the hypervisor via SSH to run a wget command manually: the proxmox_download_file resource in image.tf takes care of this directly:
locals {
flatcar_image_url = "https://${var.flatcar_channel}.release.flatcar-linux.net/amd64-usr/${var.flatcar_version}/flatcar_production_proxmoxve_image.img"
}
resource "proxmox_download_file" "flatcar" {
[...]
content_type = "import"
url = local.flatcar_image_url
file_name = "flatcar_production_proxmoxve_image-${var.flatcar_channel}.qcow2"
overwrite = false
[...]
}This image can be selected using, amongst other things, the variables flatcar_channel (which accepts stable, beta, alpha or lts) and flatcar_version.
Two important points:
Firstly, the
file_nameattribute: the image published by Flatcar is calledflatcar_production_proxmoxve_image.img, although it is actually an image in QCOW2 format. However, Proxmox VE relies strictly on the file extension to determine the import format and will simply reject a.imgfile. This means you need to rename it to.qcow2;Next,
overwrite = false: as the URL points by default to thecurrentversion, the remote file changes every time Flatcar publishes a new version. Leaving this attribute set totruewould trigger a new download as soon as a new version becomes available.
This is where the link to the first part becomes meaningful: this base image is only used to instantiate the machine on the very first boot. Once the virtual machine has started, it is Flatcar’s internal update_engine service that handles updates to the passive partition.
You therefore do not need to download every new image or redeploy your machines to keep up with OS security patches.
Ignition and its configuration#
Let’s move on to the core of the deployment with the ignition.tf file. The community-terraform-providers/ignition provider has a distinctive feature: it has no resources, only data sources.
It is a purely logical provider that assembles data structures and returns JSON compliant with the Ignition specification, without any comma errors or unclosed braces…
A quick note on versions: version 2.7.x of the provider generates JSON stamped with the Ignition 3.6.0 specification. However, the current stable branch of Flatcar (version 4757.2.2 at the time of writing) includes Ignition 2.24.0, which supports the specification up to version 3.5.0.
If you use version 2.7.x of the provider, Ignition will reject the configuration in the initramfs and the machine will not boot any further.
You can therefore see why the provider has the rule ~> 2.6.0 rather than the latest version.
Here is a summary of the data sources used in the ignition.tf file:
| Data source | Role in the configuration |
|---|---|
ignition_user | Configures the core system user and associates the SSH public key with it |
ignition_directory | Creates the /etc/blocky directory on the root filesystem |
ignition_file | Writes the configuration files (/etc/hostname, network, resolved, Blocky, update.conf) |
ignition_link | Creates the symbolic link /etc/localtime to set the time zone |
ignition_systemd_unit | Declares and activates the systemd unit blocky.service |
ignition_config | Merges all of the above elements into the final JSON document |
To begin with, take a look at the main sections of the ignition.tf file, starting with the default user core and the machine name:
data "ignition_user" "core" {
name = "core"
ssh_authorized_keys = [trimspace(file(pathexpand(var.ssh_public_key_path)))]
}
data "ignition_file" "hostname" {
path = "/etc/hostname"
mode = 420 # 0644
contents {
source = "data:;base64,${base64encode("${var.vm_name}\n")}"
}
}
[...]File permissions (mode) are expressed in decimal notation in the provider: 420 corresponds to 0644 in octal (0o644), and 493 corresponds to 0755 (0o755).
The network is then configured via a file called /etc/systemd/network/00-static.network:
data "ignition_file" "network" {
path = "/etc/systemd/network/00-static.network"
mode = 420
contents {
source = "data:;base64,${base64encode(<<-EOT
[Match]
Name=en*
[Network]
Address=${var.vm_ip_address}
Gateway=${var.vm_gateway}
DNS=${var.host_dns_server}
EOT
)}"
}
}An additional file is required. This is because Flatcar starts systemd-resolved with its stub listener, which is already listening on 127.0.0.53:53. However, Docker will attempt to publish port 53 on 0.0.0.0, covering this address: the bind fails and the Blocky container will never start. This means the stub listener must be disabled using a specific configuration such as this:
data "ignition_file" "resolved_stub" {
path = "/etc/systemd/resolved.conf.d/10-disable-stub.conf"
mode = 420
contents {
source = "data:;base64,${base64encode(<<-EOT
[Resolve]
DNSStubListener=no
EOT
)}"
}
}systemd-resolved continues to handle resolution for the machine itself via /run/systemd/resolve/resolv.conf; it simply stops listening on port 53.
Next comes the configuration of Blocky and its systemd service. The files/blocky.yml file is injected using the templatefile() function, then mounted into the container within blocky.service:
data "ignition_file" "blocky_config" {
path = "/etc/blocky/config.yml"
mode = 420
contents {
source = "data:;base64,${base64encode(templatefile("${path.module}/files/blocky.yml", {
dns_port = local.blocky_dns_port
http_port = local.blocky_http_port
}))}"
}
}
data "ignition_systemd_unit" "blocky" {
name = "blocky.service"
enabled = true
content = <<-EOT
[Unit]
Description=Blocky DNS
After=docker.service network-online.target systemd-resolved.service
[...]
[Service]
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f blocky
ExecStartPre=/usr/bin/docker pull ${local.blocky_image}
ExecStart=/usr/bin/docker run --name blocky --rm \
-p ${local.blocky_dns_port}:${local.blocky_dns_port}/udp \
-p ${local.blocky_dns_port}:${local.blocky_dns_port}/tcp \
-p ${local.blocky_http_port}:${local.blocky_http_port}/tcp \
-v /etc/blocky/config.yml:/app/config.yml:ro \
${local.blocky_image}
[...]
EOT
}Before putting it all together, one last file is added: /etc/flatcar/update.conf. In the first part, the concept was explained: Flatcar automatically downloads its updates in the background and switches between partitions on reboot.
To prevent the DNS server from restarting in the middle of the day as soon as an update is ready, locksmithd is configured to allow an automatic restart only on Sunday mornings at 04:00, within a one-hour window:
data "ignition_file" "update_conf" {
path = "/etc/flatcar/update.conf"
mode = 420
overwrite = true
contents {
source = "data:;base64,${base64encode(<<-EOT
GROUP=${var.flatcar_channel}
REBOOT_STRATEGY=reboot
LOCKSMITHD_REBOOT_WINDOW_START=Sun 04:00
LOCKSMITHD_REBOOT_WINDOW_LENGTH=1h
EOT
)}"
}
}Note the presence of overwrite = true on this file. This is because /etc/flatcar/update.conf already exists in the Flatcar base image. Without this attribute, Ignition would refuse to overwrite the existing file.
All that remains is to group all these building blocks together in the ignition_config data source:
data "ignition_config" "blocky" {
users = [data.ignition_user.core.rendered]
directories = [data.ignition_directory.blocky.rendered]
files = [
data.ignition_file.hostname.rendered,
data.ignition_file.network.rendered,
[...]
]
links = [data.ignition_link.timezone.rendered]
systemd = [data.ignition_systemd_unit.blocky.rendered]
}The data.ignition_config.blocky.rendered attribute now contains the complete JSON document, exactly like the one obtained using the butane binary in part one. You can also inspect an extract of the output using the tofu console command if required:
{
"ignition": { "version": "3.5.0" },
"passwd": {
"users": [{ "name": "core", "sshAuthorizedKeys": ["ssh-ed25519 AAAA..."] }]
},
"storage": {
"files": [
{
"contents": { "source": "data:;base64,YmxvY2t5Cg==" },
"mode": 420,
"path": "/etc/hostname"
}
[...]
]
},
"systemd": { "units": [...] }
}As mentioned in the first blog post, this JSON will be evaluated atomically within the initramfs: if even a single directive is invalid, the machine will refuse to boot rather than end up in a partially configured state.
Getting ready for deployment#
Everything is ready to create the virtual machine in main.tf. The first step is to inject the generated Ignition JSON as a snippet onto the Proxmox node, then associate it with the virtual machine.
Please note, however, that there are a few points to bear in mind regarding the code:
The first concerns boot-up: the Flatcar image requires UEFI. You must therefore switch the machine’s BIOS to
ovmfwith an EFI disk to avoid errors during boot;The second relates to the system disk: it is imported directly from the image downloaded in the previous step. It is given a size larger than that of the image, and on first boot, Flatcar automatically extends its root partition to utilise the available space;
The final point is the bridge between Proxmox and Ignition. Proxmox attaches a small cloud-init drive to the virtual machine, and Flatcar’s Proxmox image can read the
user-datato pass it on to Ignition. It is essential to establish this link via theinitialization.user_data_file_idfield within this resource.
But first, the configuration and secrets must be set up:
cp terraform.tfvars.example terraform.tfvars
export TF_VAR_proxmox_api_token="tofu@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export TF_VAR_encryption_passphrase="a-long-random-passphrase"
tofu init && tofu validate
tofu planHere is an overview of the main lines returned by tofu plan:
OpenTofu will perform the following actions:
# proxmox_download_file.flatcar will be created
+ resource "proxmox_download_file" "flatcar" {
+ content_type = "import"
+ file_name = "flatcar_production_proxmoxve_image-stable.qcow2"
+ url = "https://stable.release.flatcar-linux.net/amd64-usr/current/flatcar_production_proxmoxve_image.img"
[...]
}
# proxmox_virtual_environment_file.ignition will be created
+ resource "proxmox_virtual_environment_file" "ignition" {
+ content_type = "snippets"
+ source_raw {
+ file_name = "blocky-ignition.json"
[...]
}
}
# proxmox_virtual_environment_vm.blocky will be created
+ resource "proxmox_virtual_environment_vm" "blocky" {
[...]
+ name = "blocky"
+ vm_id = 1000
[...]
}
Plan: 3 to add, 0 to change, 0 to destroy.Although rather lengthy, the plan is straightforward: these three resources are ready to be deployed.
$ tofu apply
[...]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
blocky_api_url = "http://192.168.1.100:4000"
ssh_command = "ssh [email protected]"
vm_ip_address = "192.168.1.100"In just a few minutes (especially the first time, when downloading the image), the virtual machine is created, started up and configured.
A few steps to check everything!#
Once the instance has been deployed, you can check that everything is configured as intended! The best way to do this is to connect via SSH using ssh [email protected]:
$ cat /etc/os-release
NAME="Flatcar Container Linux by Kinvolk"
ID="flatcar"
ID_LIKE="coreos"
VERSION="4757.2.2"
[...]
$ sudo touch /usr/test
touch: cannot touch '/usr/test': Read-only file systemAs you can see, it’s impossible to change anything in /usr, this is definitely the right OS. :-)
You can check the status of the update_engine service and the Blocky container using the relevant commands:
$ update_engine_client -status
CURRENT_OP=UPDATE_STATUS_IDLE
[...]
$ systemctl is-active blocky.service
active
$ docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
NAMES IMAGE STATUS
blocky ghcr.io/0xerr0r/blocky:v0.35.0 Up 2 minutes (healthy)Outside the virtual machine, you can also test the DNS resolution:
$ dig +short @192.168.1.100 blog.filador.ch
188.114.96.12
[...]
$ dig +short @192.168.1.100 pagead2.googlesyndication.com
0.0.0.0My blog resolves correctly, while the advertising domain returns 0.0.0.0 thanks to StevenBlack’s list. The DNS server is fully operational!
The cattle model in practice: modifying and recreating#
What happens now if you want to change Blocky’s configuration, for example to add a new block list in files/blocky.yml?
You must always bear in mind Ignition’s golden rule: it runs only once, when the virtual machine is first started. If you simply edit the snippet file on Proxmox VE without recreating the virtual machine, absolutely nothing will happen, even if you restart the VM.
This is precisely the purpose of the lifecycle block placed at the end of the proxmox_virtual_environment_vm resource in main.tf:
lifecycle {
replace_triggered_by = [
proxmox_virtual_environment_file.ignition,
]
ignore_changes = [
disk[0].import_from,
]
}Thanks to the replace_triggered_by directive, as soon as the content of the Ignition JSON changes (a modification to a list in files/blocky.yml, a change to the version of the Blocky image, or the addition of a systemd unit or an SSH key), the proxmox_virtual_environment_file.ignition resource is replaced, triggering the complete replacement of the virtual machine automatically during the tofu plan:
$ tofu plan
[...]
# proxmox_virtual_environment_file.ignition must be replaced
-/+ resource "proxmox_virtual_environment_file" "ignition" {
~ id = "local:snippets/blocky-ignition.json" -> (known after apply)
[...]
}
# proxmox_virtual_environment_vm.blocky will be replaced due to changes in replace_triggered_by
-/+ resource "proxmox_virtual_environment_vm" "blocky" {
~ id = "1000" -> (known after apply)
name = "blocky"
[...]
}
Plan: 2 to add, 0 to change, 2 to destroy.This is a practical example of the cattle model: you never log in to the machine to edit /etc/blocky/config.yml manually and restart the service. You modify the single source of truth in Git, and OpenTofu destroys the old machine and replaces it within seconds with a brand-new, clean instance that is guaranteed to have no configuration drift.
Closing remarks#
With just a few HCL files, you now have a Flatcar Container Linux virtual machine that is fully reproducible on Proxmox, deploying a functional Blocky DNS service without any manual intervention, all accompanied by automatic updates with a maintenance window.
You will find exactly the same philosophy and the same declarative discipline as those implemented with Talos Linux and Cluster API on Kubernetes clusters, but applied here to the infrastructure services that support them.
To take things further, I’d encourage you to look into extensions such as keepalived.sysext for a multi-machine cluster with a VIP set up to ensure high availability, or to use Flatcar as a replacement for Talos Linux, the choice is yours!




