Staging was created by clicking in the Hetzner panel on a Friday evening. Six months later nobody knows why the firewall allows public port 6379. Prod is in Terraform — except the S3 bucket added manually "temporarily". Result: two truths, a terraform plan that wants to destroy what the team still uses.
Terraform promises reproducibility. It only delivers if state, modules and governance match application code rigour.
Providers, resources and what to model
European providers (Hetzner hcloud, OVH, Scaleway, OpenStack) expose servers, volumes, private networks, DNS, load balancers. Model what changes often and what must be traceable:
| Resource | Terraform value | Pitfall |
|---|---|---|
| Server / instance | Sized, imaged, region coded | lifecycle ignore_changes hides drift |
| Firewall / security group | Versioned rules | Manual rule outside TF |
| DNS | Deploy alignment | Forgotten TTL and propagation |
| Object storage | Buckets, lifecycle | IAM keys outside encrypted state |
If it is not in state, it is not reproducible — it is debt.
Remote state, lock and secrets
Remote backend (S3 + lock, GitLab HTTP backend, Terraform Cloud):
- Lock during apply — no concurrent applies.
- Backup state — it may contain sensitive data.
- Encryption at rest; minimal access.
Never commit plain terraform.tfstate. Use sensitive variables (TF_VAR_, CI vault) for host API tokens.
Modules: DRY without black box
Reusable modules (vpc + subnet + bastion) accelerate — but opaque catch-all modules discourage review. Expose explicit variables (instance_type, enable_ipv6, backup_window).
Version modules with Git tags (?ref=v1.2.0) — not floating main. In team, terraform fmt and validate in CI before plan on MR.
Plan, apply and change policy
Healthy workflow: MR → commented terraform plan in CI; human review of destroy and replace; automated or manual apply by criticality.
prevent_destroy on prod DB. -target for documented emergencies only — otherwise inconsistent state.
Progressive legacy import: one resource at a time, green plan before next.
Multi-cloud and European hosts
Avoid multi-cloud by default — one well-mastered provider beats three half-configs. For EU residence, set region and verify associated resources (snapshot region, replica).
Cross-check GDPR subcontractor contracts: Terraform does not guarantee jurisdiction — you choose region in code.
The peak: infra as code without governance = disguised clickops
Reproducible means: any engineer can recreate staging from scratch — not merely that a .tf file exists.
Decide and move forward without blind spots
Configure remote backend and lock access. Integrate terraform plan in CI on every merge request. Version modules with Git tags. Plan legacy import one resource at a time. Separate state per environment — never staging and prod in same state file.
Compare providers via directory and compare tool. Final test: destroy and recreate staging from git — document duration and surprises.
Frequently asked questions
Why store Terraform state remotely?
Local state on a laptop blocks teamwork and is lost on disk crash. Remote backend locks concurrent applies and histories changes.
Does Terraform replace Ansible?
No — Terraform provisions infrastructure (servers, network, DNS, buckets); Ansible or cloud-init configures OS and services. They complement each other.
How to avoid drift?
Regular plan in CI, ban manual changes not reflected in code, documented import for legacy resources.
One workspace per environment?
Yes — separate staging and production state. Never same state for two critical environments.
Useful Terraform is read in a human-reviewed plan — not an apply launched from a laptop without witness.