In part 8, we stood up the three-node Proxmox cluster with Ansible. We now have a lovely piece of infrastructure… that we still drive by hand, from my computer. This part is the one that consolidates the foundations: everything needed to provision the infra as code (OpenTofu), with an encrypted state and a CI that runs inside the cluster to execute all of it.
And it’s also the part with the most universal running theme of the whole series: the pipeline reveals everything you implicitly assumed was already on your machine. You think you have reproducible infra, right up until a brand-new runner refuses to find yq, or an SSH key that only exists on your box.
A little chicken-and-egg paradox I’ve made my peace with: the very first tofu apply runs from my computer (there’s no CI or runner yet). That first run is precisely what creates the GitLab runner, and only once that runner is up can the pipeline take over.
Step 1: an API token for Proxmox#
For OpenTofu to talk to Proxmox, it needs API access. Using the root password is out of the question: I create a dedicated user and a revocable token.
What privilege level?#
The question deserves some thought. Two options:
- A broad role (
PVEAdmin): simple, it covers everything we’ll need. - A minimal custom role: cleaner on paper, but you discover the missing permissions as you go, with
Error 403s that are a pain to diagnose.
I went for simplicity, ready to refine later once the actually-used permission set is known. Since the token is dedicated and revocable, the risk stays under control. A small adjustment along the way all the same: PVEAdmin on its own isn’t enough, it’s missing Sys.Modify (needed to download a file from a URL, which will come in handy from the next phase onwards). So I add PVESysAdmin, thinking it grants that privilege. (Spoiler: it doesn’t, but I won’t find that out until I actually use it. We’ll come back to this.)
The pve_api_token role#
ansible/roles/pve_api_token/
├── defaults/main.yml # user, roles, token id
└── tasks/main.yml # user + ACL + token creation, all idempotent# ansible/roles/pve_api_token/defaults/main.yml
---
pve_api_token_user: tofu@pve
pve_api_token_id: opentofu
pve_api_token_roles:
- PVEAdmin
- PVESysAdmin
pve_api_token_path: /# ansible/roles/pve_api_token/tasks/main.yml
---
- name: Check whether API user exists
ansible.builtin.command: "pveum user list --output-format json"
register: pve_api_token_users
changed_when: false
- name: Create API user
ansible.builtin.command: "pveum user add {{ pve_api_token_user }} --comment 'OpenTofu automation'"
when: pve_api_token_user not in (pve_api_token_users.stdout | from_json | map(attribute='userid') | list)
changed_when: true
- name: Read current ACL
ansible.builtin.command: "pveum acl list --output-format json"
register: pve_api_token_acl
changed_when: false
- name: Grant roles to API user
ansible.builtin.command: >-
pveum acl modify {{ pve_api_token_path }}
--users {{ pve_api_token_user }}
--roles {{ item }}
loop: "{{ pve_api_token_roles }}"
when: >-
pve_api_token_acl.stdout | from_json
| selectattr('ugid', 'equalto', pve_api_token_user)
| selectattr('roleid', 'equalto', item)
| list | length == 0
changed_when: true
- name: Check whether token exists
ansible.builtin.command: "pveum user token list {{ pve_api_token_user }} --output-format json"
register: pve_api_token_existing
changed_when: false
- name: Create API token
ansible.builtin.command: >-
pveum user token add {{ pve_api_token_user }} {{ pve_api_token_id }}
--privsep 0
--output-format json
register: pve_api_token_new
when: pve_api_token_id not in (pve_api_token_existing.stdout | from_json | map(attribute='tokenid') | list)
changed_when: true
- name: Show token secret
# noqa: no-handler - one-shot display of a secret shown only at creation
ansible.builtin.debug:
msg: >-
Token created. Store it now, it will not be shown again:
{{ (pve_api_token_new.stdout | from_json).value }}
when: pve_api_token_new.changedAs with the storage in part 8, this role only runs on the primary node: the playbook targets hosts: pve but conditions the task with when: inventory_hostname == pve_cluster_primary. Users, tokens and ACLs live in pmxcfs and propagate to the rest of the cluster, no need to create them three times.
Two points are worth highlighting:
--privsep 0: by default, a Proxmox token has its own permissions, distinct from those of its user (you’d have to set a separate ACL on it). Withprivsep 0, it inherits the user’s rights. This keeps control at the user level, and gives us two levels of revocation: the token alone, or the entire user.- The secret is only shown once, at creation. Hence the
debugtask (with a deliberate# noqa: no-handler: displaying an ephemeral secret is legitimate) to capture it and tuck it away in SOPS right away.
Idempotence, again and always. Each step checks the state before acting (pveum user list, pveum acl list, pveum user token list). An earlier version had a hard-coded changed_when: true on the role assignment → changed on every run. The linter saw nothing: it catches conventions, not logic. Only the idempotence test reveals this kind of bug.
The secret tucked away in SOPS#
The token (and the other secrets for this phase) lands in secrets/tofu.sops.yaml. Here are the keys it holds (encrypted values, obviously):
proxmox_api_token_id # tofu@pve!opentofu
proxmox_api_token_secret # the generated UUID
gitlab_project_id # the GitLab project id
gitlab_token # project PAT, api scope
tofu_state_passphrase # openssl rand -base64 32
gitlab_runner_token # glrt-...Least privilege everywhere. The GitLab PAT is a project token (not an account token): it only grants access to kentrowlab. I even gave it a short expiry (7 days) to test renewal. The day it expires: a 401 on tofu init, and I’ll just need to regenerate the token and update the key in SOPS.
The zsh aside (because we have to talk about it)#
Handling a Proxmox token in a zsh shell is a bit of a minefield. Three traps encountered, all genuine:
read -pdoesn’t exist in zsh: it spits outread: -p: no coprocess. The zsh syntax isread -rs "VAR?prompt". The most portable option remainsecho -n "prompt"; read -rs VAR.- Name / value confusion: in
read -rs "VAR?prompt",VARis the name of the variable. Pasting the secret straight in produceszsh: not an identifierand, worse, writes the secret into your history. (Token regenerated and~/.zsh_historycleaned up right after.) != history expansion: a Proxmox token ID always contains a!(tofu@pve!opentofu). Pasting it as-is giveszsh: event not found. The workaround: build the value via command substitution, where the!isn’t interpreted.
Validation#
A curl with the token confirms the API responds:
TOKEN_ID=$(sops -d secrets/tofu.sops.yaml | yq -r '.proxmox_api_token_id')
TOKEN_SECRET=$(sops -d secrets/tofu.sops.yaml | yq -r '.proxmox_api_token_secret')
AUTH="PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}"
curl -sk -H "Authorization: ${AUTH}" https://192.168.3.11:8006/api2/json/nodes | jq -r '.data[].node'pve01
pve02
pve03The token sees all three nodes. OpenTofu can take over.
Step 2: OpenTofu, GitLab backend and encrypted state#
This is the heart of the phase. We want OpenTofu to store its state (the state of the infra) in GitLab, and above all to have it encrypted client-side: even if the GitLab backend leaked, the state must stay unreadable.
A technical constraint that shapes everything#
OpenTofu’s encryption block is evaluated very early, before variables. So it’s impossible to inject the passphrase into it via a var.. The intended method is the TF_ENCRYPTION environment variable, which carries the entire encryption config.
Hence an env.sh script: it decrypts SOPS and exports everything needed (backend, encryption, Proxmox credentials). Like render.sh in part 7, it contains no hard-coded secrets: everything comes from SOPS at runtime.
# tofu/env.sh
#!/usr/bin/env bash
_tofu_env() {
local stack="${1:-core}"
local root secrets plain project_id gl_token address passphrase
root="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$root" ]; then
echo "error: run from inside the git repository" >&2
return 1
fi
secrets="$root/secrets/tofu.sops.yaml"
plain="$(sops -d "$secrets")" || { echo "error: cannot decrypt $secrets" >&2; return 1; }
_get() { printf '%s' "$plain" | yq -r ".$1"; }
project_id="$(_get gitlab_project_id)"
gl_token="$(_get gitlab_token)"
passphrase="$(_get tofu_state_passphrase)"
address="https://gitlab.com/api/v4/projects/${project_id}/terraform/state/${stack}"
export TF_HTTP_ADDRESS="$address"
export TF_HTTP_LOCK_ADDRESS="${address}/lock"
export TF_HTTP_UNLOCK_ADDRESS="${address}/lock"
export TF_HTTP_LOCK_METHOD="POST"
export TF_HTTP_UNLOCK_METHOD="DELETE"
export TF_HTTP_USERNAME="gitlab-ci-token"
export TF_HTTP_PASSWORD="$gl_token"
export TF_ENCRYPTION="key_provider \"pbkdf2\" \"state\" {
passphrase = \"${passphrase}\"
}
method \"aes_gcm\" \"state\" {
keys = key_provider.pbkdf2.state
}
state {
method = method.aes_gcm.state
enforced = true
}
plan {
method = method.aes_gcm.state
enforced = true
}"
export PROXMOX_VE_API_TOKEN="$(_get proxmox_api_token_id)=$(_get proxmox_api_token_secret)"
local bootstrap
bootstrap="$(sops -d "$root/secrets/bootstrap.sops.yaml")" || return 1
export PROXMOX_VE_SSH_USERNAME="root"
export PROXMOX_VE_SSH_PRIVATE_KEY="$(printf '%s' "$bootstrap" | yq -r '.ssh_private_key')"
export TF_VAR_ssh_public_key="$(printf '%s' "$bootstrap" | yq -r '.ssh_public_key')"
# ... (more TF_VARs will come for the next phases)
echo "OpenTofu environment ready for stack: $stack"
}
_tofu_env "$@"
unset -f _tofu_envA sourced script isn’t written the same way as an executed one. My first version had a set -euo pipefail at the top. Since we source this script, those options apply to the interactive shell: the slightest command that fails closes the terminal (terminated with exit code: 100). Second trap, ${BASH_SOURCE[0]} doesn’t exist in zsh. The workaround: no set -e (errors handled by return 1 inside a function), and git rev-parse --show-toplevel to find the root, regardless of the shell.
One detail of the TF_ENCRYPTION block deserves a pause: the enforced = true, set on the state as well as the plan. It doesn’t mean “encrypt if possible”, but “OpenTofu refuses to write anything in the clear”. No silent fallback the day the passphrase isn’t loaded: a hard error rather than a plaintext state by accident.
The core stack#
tofu/stacks/core/
├── versions.tf # required_version, bpg provider, empty http backend
├── providers.tf # endpoint, ssh
├── variables.tf # the stack's variables
├── main.tf # verification data source
└── runner.tf # (step 4)The versions.tf declares the provider and an empty http backend: all of its config comes from environment variables, so there’s no secret or specific URL in the code, it can be published as-is.
# tofu/stacks/core/versions.tf
terraform {
required_version = ">= 1.12.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.100"
}
# (the Talos, Helm and Kubernetes providers will come in the next phases)
}
# Credentials and encryption come from env vars (see tofu/env.sh)
backend "http" {}
}# tofu/stacks/core/providers.tf
provider "proxmox" {
endpoint = var.proxmox_endpoint
insecure = true
# API token and SSH credentials come from environment (see tofu/env.sh)
ssh {
agent = false
}
}The insecure = true isn’t carelessness: internally, the Proxmox API runs with a self-signed certificate. Valid certificates (via ktw.ovh and Let’s Encrypt) will come in a dedicated phase; for now, we accept the self-signed cert on the LAN.
The stack’s variables, cut down to what’s used in phase 3 (the file will grow later with Talos, Cilium, ArgoCD…):
# tofu/stacks/core/variables.tf
variable "proxmox_endpoint" {
description = "Proxmox VE API endpoint"
type = string
default = "https://192.168.3.11:8006/"
}
variable "runner_node" {
description = "Node hosting the GitLab runner container"
type = string
default = "pve01"
}
variable "runner_template" {
description = "LXC template file id (must exist on runner_node)"
type = string
default = "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst"
}
variable "runner_vm_id" {
description = "Container id"
type = number
default = 200
}
variable "runner_ip" {
description = "Static IPv4 with CIDR"
type = string
default = "192.168.3.50/24"
}
variable "runner_gateway" {
type = string
default = "192.168.3.1"
}
variable "runner_dns" {
type = string
default = "1.1.1.1"
}
variable "storage_pool" {
description = "ZFS pool backing container volumes"
type = string
default = "nvme-vm"
}
variable "ssh_public_key" {
description = "Public key injected into containers (set via TF_VAR_ssh_public_key)"
type = string
}And the main.tf, for now, just does a read-only check: does the provider actually reach the cluster?
# tofu/stacks/core/main.tf
data "proxmox_virtual_environment_nodes" "cluster" {}
output "cluster_nodes" {
description = "Nodes seen through the API"
value = data.proxmox_virtual_environment_nodes.cluster.names
}Prove the encryption, don’t assume it#
This is my favourite moment of the phase. After source tofu/env.sh core, a tofu init then a tofu apply (which creates no resource, but initialises the state on the GitLab side). Then we go and look at what GitLab actually stores:
curl -s -u "gitlab-ci-token:$TF_HTTP_PASSWORD" "$TF_HTTP_ADDRESS" | head -c 400{"serial":1,"lineage":"31f7af38-...","meta":{"key_provider.pbkdf2.state":
"eyJzYWx0IjoiRDlxTUpDUnZ4WURkUHBIZWF3ZURmWlhOblZLK3ZoNExDM2Z2Z3U0NittOD0i
LCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ
3RoIjozMn0="},"encrypted_data":"TfPgZfdLvDWiHTp9WWGotfF730BOX2A+..."}Decoding it (figuratively):
encrypted_data: an unreadable blob. No trace ofpve01, of the endpoint, of anything at all.key_provider.pbkdf2.state: just the derivation metadata (salt, 600,000 iterations, SHA-512). Not the key.- Only
serialandlineageare in the clear: versioning metadata, with no value.
This is the best reason to choose OpenTofu over Terraform, far more compelling than the licence debate: client-side state encryption, native. Even if the GitLab backend leaked, the state stays undecipherable without the passphrase, which itself lives in SOPS, protected by the age key. The chain is coherent end to end.
Two pre-commit snags along the way, nothing serious: terraform_fmt fails on the first commit (it’s an auto-correcting hook: it reformats then stops so you can review, a git add + recommit is enough), and tflint complained about a variable declared but not used (added “just in case”: removed, which is exactly what the linter is there to prevent).
Step 3: the LXC template (Ansible)#
Before creating a container, you need its template. And here, Ansible is the right tool, not OpenTofu: pveam is a local command, and the provider doesn’t manage the Proxmox catalogue.
ansible/roles/pve_lxc_template/
├── defaults/main.yml # template pattern + target storage
└── tasks/main.yml # catalogue refresh + idempotent download# ansible/roles/pve_lxc_template/defaults/main.yml
---
pve_lxc_template_pattern: debian-13-standard
pve_lxc_template_storage: local# ansible/roles/pve_lxc_template/tasks/main.yml
---
- name: Refresh appliance catalog
ansible.builtin.command: pveam update
changed_when: false
- name: Find latest matching template
ansible.builtin.shell: |
set -o pipefail
pveam available --section system \
| awk '{print $2}' \
| grep "^{{ pve_lxc_template_pattern }}" \
| sort -V | tail -1
args:
executable: /bin/bash
register: pve_lxc_template_latest
changed_when: false
failed_when: pve_lxc_template_latest.stdout | length == 0
- name: List downloaded templates
ansible.builtin.command: "pveam list {{ pve_lxc_template_storage }}"
register: pve_lxc_template_present
changed_when: false
- name: Download template
ansible.builtin.command: >-
pveam download {{ pve_lxc_template_storage }}
{{ pve_lxc_template_latest.stdout }}
when: pve_lxc_template_latest.stdout not in pve_lxc_template_present.stdout
changed_when: trueThe role refreshes the catalogue, finds the latest version matching a pattern (debian-13-standard), and downloads it if it’s absent. Result: local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst.
Worth noting: this particular download goes through pveam over SSH root, not through the API token. So it’s not what required the Sys.Modify added in step 1 - that’s for downloads via the OpenTofu API, which will come later.
Careful, local isn’t shared between nodes. The template is downloaded on pve01, so the container will have to be created there. To deploy elsewhere, you’d also need to download the template on that other node. It’s a Proxmox subtlety that trips you up quickly.
Step 4: the GitLab runner, created by OpenTofu#
At last the first real resource created in code: an LXC container that will host the GitLab runner.
# tofu/stacks/core/runner.tf
resource "proxmox_virtual_environment_container" "gitlab_runner" {
node_name = var.runner_node
vm_id = var.runner_vm_id
# Unprivileged: container root is not host root
unprivileged = true
start_on_boot = true
initialization {
hostname = "runner01"
ip_config {
ipv4 {
address = var.runner_ip
gateway = var.runner_gateway
}
}
dns {
servers = [var.runner_dns]
}
user_account {
keys = [trimspace(var.ssh_public_key)]
}
}
cpu {
cores = 2
}
memory {
dedicated = 2048
swap = 512
}
disk {
datastore_id = var.storage_pool
size = 20
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = var.runner_template
type = "debian"
}
# Nesting is required to run containerised CI jobs later
features {
nesting = true
}
tags = ["ci", "managed-by-tofu"]
lifecycle {
# Placement and run state belong to the HA manager, not to the code
ignore_changes = [node_name, started]
}
}The choices that matter:
unprivileged = true: the container’s root isn’t the host’s root. The recommended default.nesting = true: needed to launch containerised CI jobs later. Without it, you’re limited to theshellexecutor.- Disk on
nvme-vm: the first concrete use of the ZFS pool from part 8. ignore_changes = [node_name, started]: placement and run state belong to Proxmox’s HA manager, not to the code. Once HA is enabled (in a later phase), this avoids OpenTofu wanting to “fix” a node the VM might have migrated onto.
A detail that loops back to the previous step: template_file_id points to the template downloaded in step 3. And since local is not shared between nodes, that template only exists on pve01 - which forces runner_node to point at pve01. The Proxmox constraint from step 3 directly dictates the placement of the resource here.
The mountpoint=none bug: part 8 comes back to haunt me#
Remember: in part 8, I’d insisted on the -m /nvme-vm of the ZFS pool, with a note along the lines of “lesson learned the hard way”. Here’s that hard lesson, revealed at exactly this moment, at the very first container:
Error: unable to create CT 200 - zfs error:
cannot mount 'nvme-vm/subvol-200-disk-0': no mountpoint setOriginally, my pool was created with -m none (“Proxmox manages the datasets itself”). That reasoning is true for VMs: they use zvols, block devices with no mountpoint. But false for LXC containers: they use subvols, ZFS datasets that Proxmox has to mount. Since children inherit mountpoint=none, the mount fails.
The fix: zfs set mountpoint=/nvme-vm nvme-vm on all three nodes, and above all fixing the pve_zfs role (the -m /nvme-vm you saw in part 8, plus the zfs set task for already-existing pools).
A phase 2 decision whose consequence only shows up in phase 3. The error only surfaces at the first container, long after the pool was created. It’s the perfect example of a choice that “works” as long as you don’t use it the right way. The rule to remember: a ZFS pool destined for Proxmox must not have mountpoint=none if you plan to create containers on it.
Once the pool was fixed, the container creates without complaint: runner01, Debian 13.6, 2 GB of RAM, 20 GB disk on nvme-vm.
Step 5: the runner and the pipeline, or the great moment of truth#
The container exists, now we have to install and register the GitLab runner on it, then write the pipeline. This is where the phase’s running theme really lets loose.
The gitlab_runner role#
# ansible/roles/gitlab_runner/tasks/main.yml (extract)
- name: Register runner
ansible.builtin.command: >-
gitlab-runner register
--non-interactive
--url {{ gitlab_runner_url }}
--token {{ gitlab_runner_token }}
--executor {{ gitlab_runner_executor }}
--shell bash
--name {{ gitlab_runner_name }}
when: gitlab_runner_name not in gitlab_runner_list.stderr
changed_when: true
no_log: true
- name: Install yq
ansible.builtin.get_url:
url: https://github.com/mikefarah/yq/releases/download/v4.44.6/yq_linux_amd64
dest: /usr/local/bin/yq
mode: "0755"
- name: Install sops
ansible.builtin.get_url:
url: https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
dest: /usr/local/bin/sops
mode: "0755"
- name: Install opentofu
ansible.builtin.shell: |
set -o pipefail
curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-tofu.sh
chmod +x /tmp/install-tofu.sh
/tmp/install-tofu.sh --install-method standalone --skip-verify
rm -f /tmp/install-tofu.sh
args:
executable: /bin/bash
creates: /usr/local/bin/tofuTwo choices: the shell executor (rather than docker) - simpler, sufficient to run tofu and ansible, and it avoids nesting Docker inside LXC. And the bookworm suite for the runner’s repository (GitLab doesn’t yet publish for trixie, and the packages are compatible).
Note above all the end of the role: we explicitly install the tools the pipeline will need on the runner - yq, sops, opentofu and age (the extract above shows three; age follows the exact same get_url pattern). Keep this in mind, we’re about to understand why in a moment.
Three ways to read a SOPS secret in Ansible#
The playbook has to pass the gitlab_runner_token (encrypted in SOPS) to the registration command. My first version used vars_files: [../secrets/tofu.sops.yaml]… and the registration failed, silently (because of the no_log: true).
# ansible/gitlab-runner.yml
---
- name: Install and register GitLab runner
hosts: ci
become: false
gather_facts: true
vars:
# vars_files does not decrypt SOPS; the lookup plugin does
kentrowlab_secrets: >-
{{ lookup('community.sops.sops', playbook_dir + '/../secrets/tofu.sops.yaml') | from_yaml }}
gitlab_runner_token: "{{ kentrowlab_secrets.gitlab_runner_token }}"
roles:
- gitlab_runnerThere are three different SOPS mechanisms in Ansible, and you need to know which one applies:
- The vars plugin (
community.sops.sops) automatically decryptsgroup_vars/host_vars- that’s what we saw in part 8. - The lookup (
lookup('community.sops.sops', ...)) decrypts explicitly, anywhere - that’s the approach used here. vars_filesdecrypts NOTHING: Ansible reads the encrypted YAML as-is and hands youENC[...]blobs as values.
And a corollary: no_log: true protects secrets in the logs, but makes debugging blind. The reflex when something goes wrong: temporarily disable it to diagnose, then put it back. Never leave it disabled “just in case”.
The only secret placed by hand#
For the pipelines to be able to decrypt SOPS, the runner needs the private age key. It’s placed manually in GitLab (Settings → CI/CD → Variables), as Masked + Protected, under the name SOPS_AGE_KEY. It’s the only secret placed by hand in the whole project: everything else flows from it. Faithful to the logic of phase 0 (working key on my computer, backup in Bitwarden, CI copy as a protected variable).
The pipeline#
# .gitlab-ci.yml
include:
- template: Security/Secret-Detection.gitlab-ci.yml
stages:
- test
- security
- plan
- apply
default:
tags:
- kentrowlab
variables:
TF_STACK: core
secret_detection:
# Docker-based template: keep it on GitLab.com shared runners
tags: []
sops-encrypted:
stage: security
script:
- |
status=0
files=$(find . -type f \( -name '*.sops.yaml' -o -name '*.sops.yml' \
-o -name '*.sops.json' -o -name '*.sops.env' \) \
! -path './.sops.yaml')
for f in $files; do
if grep -q 'ENC\[' "$f"; then echo "OK (encrypted): $f";
else echo "ERROR (PLAINTEXT): $f"; status=1; fi
done
exit $status
.tofu_base:
before_script:
- mkdir -p ~/.config/sops/age
- echo "$SOPS_AGE_KEY" > ~/.config/sops/age/keys.txt
- chmod 600 ~/.config/sops/age/keys.txt
- source tofu/env.sh "$TF_STACK"
- cd "tofu/stacks/$TF_STACK"
- tofu init -input=false
after_script:
- rm -f ~/.config/sops/age/keys.txt
tofu-plan:
extends: .tofu_base
stage: plan
script:
- tofu plan -input=false -out=tfplan
- tofu show -no-color tfplan > plan.txt
artifacts:
paths:
- tofu/stacks/$TF_STACK/plan.txt
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
tofu-apply:
extends: .tofu_base
stage: apply
script:
- tofu apply -input=false -auto-approve
rules:
# Manual gate: a merge should never silently destroy infrastructure
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: falseThe tofu-plan runs on every merge request and on the default branch; tofu-apply, on the other hand, is manual: a merge must never destroy the infra silently, it takes a human click.
The three snags of the pipeline (the real heart of the article)#
1. The missing test stage.
secret_detection job: chosen stage test does not existGitLab’s Secret-Detection template uses the test stage, which I’d removed from my list. Reintroduced.
2. secret_detection incompatible with the shell executor.
ERROR: Job failed: exit status 127The template assumes a Docker executor. With shell, the runner ignores the image: and tries to launch an absent binary (127 = command not found). The workaround: tags: [] to send this job to GitLab.com’s shared runners (it doesn’t need LAN access). Corollary: the shell executor requires that all the tools be present on the runner - no Docker image to bring them along. That’s the price of simplicity.
3. Implicit dependencies - THE running theme.
And here, three successive failures, all of the same family:
| Error | Cause |
|---|---|
yq: command not found | yq was on my computer, forgotten in the runner’s role |
no file exists at "~/.ssh/kentrowlab_ed25519" | a private key present only on my computer |
no file exists at "~/.ssh/kentrowlab_ed25519.pub" | same for the public key |
The fix: yq added to the runner’s role (hence the explicit install seen above), and the two SSH keys now come from a SOPS file dedicated to bootstrap (bootstrap.sops.yaml, distinct from tofu.sops.yaml which carries the application secrets), read by env.sh - no more local file. Incidentally, the ssh_public_key variable has no default value: OpenTofu fails explicitly if it’s missing, rather than trying to read an absent file.
The central lesson of the phase. A configuration that depends on a file on your machine isn’t portable. The pipeline is the first genuinely neutral environment: it reveals everything you implicitly assumed was present on your box. Getting a job to pass in CI is the real reproducibility test of an infra as code - far more than a successful local tofu apply.
Final validation#
gitlab-runner 19.2.0 - OpenTofu v1.12.5 - sops 3.9.4 - service active
Pipeline: secret_detection OK | sops-encrypted OK | tofu-plan OK
Where things stand after phase 3#
| Item | State |
|---|---|
| Proxmox API token | tofu@pve!opentofu, PVEAdmin + PVESysAdmin, revocable |
| OpenTofu state | GitLab backend, client-side encrypted (pbkdf2 + aes_gcm) |
| Provider | bpg/proxmox, credentials via environment variables |
| Runner | LXC 200 on pve01, Debian 13.6, shell executor |
| Runner tooling | tofu, sops, yq, age, git |
| Pipeline | secret_detection + sops-encrypted + tofu-plan green, tofu-apply manual |
| Secrets | 6 keys in secrets/tofu.sops.yaml, SOPS_AGE_KEY as a CI variable |
We now have a real automation chain: I push code, the pipeline checks it, lints it, plans the changes, and I can apply them with a click. And the state is encrypted end to end. But above all, this phase taught me the most useful lesson of the whole project: the only real test of reproducible infra is running it somewhere other than your own machine.
Still-open items#
- The GitLab PAT expires in 7 days (deliberate, to test renewal). Expected symptom:
401ontofu init. Action: regenerate and updategitlab_tokenin SOPS. - Refine the token’s permissions: the
PVEAdmin+PVESysAdminpair is broad and, as we saw, not necessarily sufficient, we’ll come back to it.
And now?#
The foundation is there: OpenTofu drives the cluster, the state is encrypted, the CI runs in the lab. In part 10, we tackle golden images: reproducible cloud-init templates with Packer, and the Talos image pulled from the Image Factory. In short, everything needed to cleanly build the VMs that will soon host Docker and Kubernetes.
See you soon!




