Infrastructure as Code
OpenTofu drives the Infomaniak Public Cloud (OpenStack) environment, plus DNS for rjkorteschiel.nl via the Infomaniak provider. The full config is in tofu/personal-services/ at the root of this repository, .tofu files throughout.
1. Install the CLIs
brew install opentofu
brew install openstackclient
brew install protonpass/tap/pass-cli
2. Wire up credentials via Proton Pass
Credentials come from Proton Pass, not flat files. No clouds.yaml, no openrc, no ~/.s3cfg on disk — every command runs wrapped in pass-cli run --env-file .env --, which resolves pass://vault/item/field references from a local, gitignored .env and injects the real values only into that subprocess's environment. pass-cli login once per machine, then it just works.
Create three Custom Items (template: API Credential) in a vault:
| Item | Fields | Used for |
|---|---|---|
Infomaniak OpenStack App Credential | id, secret | Compute/network/storage/DNS-zone auth |
Infomaniak OpenStack S3 Access Key | access_key, secret_key | OpenTofu state backend |
Infomaniak Developer API Token | token | DNS records (Manager → Account settings → API Tokens) |
The S3 keys are a distinct credential type from the app credential above, even though both belong to the same Infomaniak project — Keystone's "EC2 credentials" mechanism, separate from application credentials, needed because the S3 gateway signs requests SigV4-style rather than with Keystone tokens. An application credential can't create new EC2 credentials either (openstack ec2 credentials create 403s under app-credential auth, by design — an anti-escalation restriction); a fresh pair needs a temporary v3password login instead. Existing ones are still readable via openstack ec2 credentials list.
Plus one more, template SSH Key: Infomaniak SSH Key — its default fields, Public key + Private key, work as-is. OpenTofu only ever reads Public key (via TF_VAR_ssh_public_key, to create the OpenStack keypair) — the private half isn't used by OpenTofu at all, only for actually SSHing into the box afterward. Vaulted together anyway so there's one place both halves live, instead of only a local ~/.ssh/ file.
Then point tofu/personal-services/.env (gitignored) at them — copy .env.example as a starting point, swapping in your own vault/item names if they differ.
3. Create the state bucket
The state bucket must exist before tofu init can connect to it — OpenTofu can't create its own backend. Create it once manually on first setup:
pass-cli run --env-file .env -- openstack container create terraform-state
4. Run OpenTofu
cd tofu/personal-services
pass-cli run --env-file .env -- tofu init
pass-cli run --env-file .env -- tofu plan
pass-cli run --env-file .env -- tofu apply
5. Switching an existing working directory from Terraform
If a directory was previously terraform init'd and you're pointing the tofu binary at it for the first time, tofu init errors with "Backend configuration changed" — it's comparing the local .terraform/ init cache (recorded under the terraform binary) against tofu's own view, not detecting any actual change to the backend "s3" {} block itself. Fix with -reconfigure, not -migrate-state:
pass-cli run --env-file .env -- tofu init -reconfigure
-reconfigure re-points at the exact same backend config already in versions.tofu without touching the state file. -migrate-state is for when the backend target itself is genuinely changing (a different bucket or key) — using it here would be unnecessary write activity against state that hasn't actually moved. Confirm the switch landed cleanly with tofu plan — it should report no changes, since nothing about the actual infrastructure did.
6. If state fails to persist
Infomaniak's S3 gateway 501s on the aws-chunked trailing-checksum upload the AWS SDK v2 sends by default — third-party S3 implementations routinely don't support it. versions.tofu's skip_s3_checksum = true is meant to cover this but doesn't fully (upstream bug, hashicorp/terraform#37432) — the actual fix is .env's AWS_REQUEST_CHECKSUM_CALCULATION=when_required and AWS_RESPONSE_CHECKSUM_VALIDATION=when_required.
If apply still fails to persist state after all that, OpenTofu doesn't lose the result — it writes the in-progress state to a local errored.tfstate file instead. Recover it once the underlying issue is actually fixed:
pass-cli run --env-file .env -- tofu state push errored.tfstate
Confirm it landed with tofu state list before continuing (plan should then only show what genuinely wasn't created yet, nothing already-existing getting recreated). Delete errored.tfstate once confirmed — and don't let a file like it linger untracked-but-committed; .gitignore's tofu/**/*.tfstate* should catch it, but that pattern only exists because one already slipped through once.
7. Destroying
tofu destroy tears down everything currently in state — including both block volumes. A volume isn't like stopping a container — destroying one loses whatever's on it permanently, no undo. Back up /mnt/data the same way as the old VPS (see System → Coolify) before destroying anything with real data on it.
The boot volume's delete_on_termination = false (compute.tofu) doesn't protect against this either — it only stops OpenStack from cascading a delete when the instance is removed directly. The volume is its own tracked resource, so tofu destroy destroys it too unless explicitly excluded.
To tear down only part of the config instead of everything, target the specific resource:
pass-cli run --env-file .env -- tofu destroy -target=openstack_compute_instance_v2.personal_services