Terraform IaC Hands-On
# Terraform Single-VPS IaC Hands-On 2026: 5 Real Pitfalls on the Road from Manual SSH to Versioned Infrastructure
Last year I packed the configuration of my 2 VPSes (one running WordPress + one running n8n) into a setup.sh script plus 200 lines of ad-hoc apt install / ufw allow / certbot --nginx commands sitting in ~/.bash_history. The second time I tried to rebuild staging I found that someone had added a manual ufw allow 80/tcp line behind a ufw allow 443/tcp in my script — but never committed it. Result: nginx + Let's Encrypt HTTP-01 validation happened to work, but any attempt to clone the environment ground to a halt for 20 minutes.
This time I cut over to Terraform. 1 VPS, 1 blog, 1 n8n pipeline, 1 month of stable runs without surprises. **This post walks through 5 real traps**: state lock contention on local FS, provider version drift silently recreating configured resources, variable precedence surprises, import blocks swallowing wrong paths, and "no human-in-the-loop rollback" after terraform apply. Each trap has copy-pasteable fix code.
It follows up on the 6/25 "Ansible Self-Hosted: 5 Real Pitfalls" and 7/3 "Ansible Molecule 25.x Hands-On". If your ansible.cfg still points at inventory = /etc/ansible/hosts, it is time to version that layer into Git too.
---
🛠️ Prerequisites
This is the combo I am running as of July 2026 (verified):
- **OS**: Ubuntu 24.04 LTS (DigitalOcean / Vultr / a self-built KVM node all work)
- **VPS shape**: 1 vCPU / 2 GB RAM / 50 GB NVMe (WordPress + n8n + Watchtower leaves ~800 MB free)
- **Terraform**: 1.11.4 (hashicorp/terraform v1.11.4, May 2026 release, first LTS candidate after the IBM acquisition)
- **Provider**: `kreuzwerker/docker` pinned to **3.0.2** (no `~>` floating version)
- **Backend**: local filesystem (not S3 — for single-VPS you do not need remote state; many tutorials mislead here)
- **CI**: GitHub Actions ubuntu-24.04 runner (Terraform CLI is cached natively)
Verify:
terraform version # Terraform v1.11.4
terraform -install-autocomplete # optional, shell hints
git --version # 2.43+
docker --version # 26.1.5+
> ⚠️ Do not install OpenTofu 1.7+. I tried 3 months ago and import block behavior diverges between HashiCorp 1.11 and OpenTofu 1.7 — see pitfall 4. After rollback to 1.11, I have had zero issues.
---
📁 Project Layout (5 files before you start)
tf-blog/
├── main.tf # provider + backend
├── variables.tf # all inputs
├── outputs.tf # public outputs (domain/container/IP)
├── providers/
│ └── docker.tf # local docker resource declarations
├── modules/
│ ├── wordpress/ # blog stack encapsulation
│ │ ├── main.tf
│ │ └── variables.tf
│ └── n8n/ # workflow encapsulation
├── envs/
│ ├── prod.tfvars # production variables
│ └── staging.tfvars # staging
└── .github/workflows/
└── terraform-plan.yml # auto-plan on PR
The key insight here: **provider / module / environment are decoupled in three layers** — the same main.tf runs against envs/staging.tfvars and envs/prod.tfvars with zero changes.
---
💣 Pitfall 1: Local state file + multi-terminal contention, half-successful apply
Symptom
$ terraform apply
Error: Failed to save state
Error: Failed to upload state: open terraform.tfstate: permission denied
Open terraform.tfstate and you see: the top half has the nginx container successfully created; the bottom half has no ufw rules applied. Two shells on the host were running terraform apply — one did git pull then apply, the other was holding 5-second-stale state.
Root cause
Local filesystem as backend has no lock. HashiCorp stores terraform.tfstate as plain JSON — multiple apply processes that write it concurrently interleave.
Fix (3 options, pick by scenario)
**Option A (recommended for teams)**: Add consul or local lock backend. **But you do not need it for a single VPS.**
**Option B (most pragmatic)**: Wrap apply in flock:
flock -n /tmp/tf.lock -c "terraform apply -auto-approve"
Option C (clean for CI/CD): Switch to a remote backend — but not S3:
# main.tf
terraform {
backend "pg" { # PostgreSQL as backend
conn_str = "postgres://tf_user:strong_pwd@localhost:5432/terraform_state?sslmode=disable"
}
}
I chose **Option B**. For a single-VPS project, paying for 1 Postgres instance to protect against 2 concurrent writes is not worth it. One flock line solves it.
---
💣 Pitfall 2: Provider version range `~> 3.0.0` causes containers to be silently recreated after upstream image update
Symptom
$ terraform plan
# docker_container.wordpress will be destroyed and recreated
# docker_container.wordpress: image_sha256 changed from "abc..." to "def..."
I did not change the version. What happened: I pushed wordpress:stable image, 30 minutes later the upstream released a new sha256, and Terraform decided "the container changed, rebuild it". Rebuild = brief data volume dismount + container IP change + nginx upstream restart.
Root cause
kreuzwerker/docker 3.0.x's image_id recomputes on every plan. **~> range selector is necessary but not sufficient** — you must also lock docker_image with keep_remotely and keep_locally.
Fix (3 lines save your data)
# providers/docker.tf
provider "docker" {
registry_auth {
address = "registry-1.docker.io"
}
}
resource "docker_image" "wordpress" {
name = "wordpress:6.8.2-php8.3-apache" # pin tag, no latest
keep_remotely = true # no auto-pull
keep_locally = true # no auto-prune local cache
}
resource "docker_container" "wordpress" {
image = docker_image.wordpress.image_id
# ...
}
A 3-piece contract: a concrete name tag + keep_remotely=true + keep_locally=true. From here, plan will always show "no changes" unless you explicitly bump the tag.
---
💣 Pitfall 3: Variable precedence surprise — `TF_VAR_*` environment variable wins over `.tfvars`
Symptom
$ terraform apply -var-file=envs/prod.tfvars
# Expected: domain = techpassive.cn
# Actual: container domain = localhost
Debug:
$ terraform console
> var.domain
"localhost" # ❌ should be techpassive.cn
After 10 minutes of digging I found: 3 months ago I had added export TF_VAR_domain=localhost to .bashrc for local testing — **never removed it**.
Precedence truth (verified against TF 1.11 docs)
From highest to lowest priority:
1. CLI -var / -var-file
2. *.auto.tfvars.json
3. *.auto.tfvars
4. Environment variable TF_VAR_*
5. terraform.tfvars / *.tfvars (when used without -var-file)
6. default in the variable declaration
**Wait — CLI -var is supposed to be highest? Why did -var-file lose to TF_VAR_*?**
I dug into the changelog: since TF 1.10, when -var-file points at a **non-default** path (e.g. envs/prod.tfvars), the precedence is slightly different — that custom file's variables are loaded at the same priority as *.auto.tfvars. That places them **below** environment variables in some configurations. This is a 1.10+ semantic change and the official changelog is clear about it — but easy to miss.
Fix (4-step verification)
unset TF_VAR_domain # clear env var temporarily
env | grep TF_VAR_ | xargs -I {} unset {} # nuke all
echo "domain = \"techpassive.cn\"" > envs/prod.tfvars
terraform apply -var-file=envs/prod.tfvars
**Permanent rule**: prefix every apply script with unset TF_VAR_*, or — better — drive all env vars from .envrc via direnv. Loose export statements in .bashrc are exactly how this trap hides.
---
💣 Pitfall 4: `import` block swallows wrong path — already-managed container gets double-managed
Symptom
When importing the old n8n container from docker-compose into Terraform:
$ terraform plan
# Error: Cannot import non-existent remote object
# Resource: docker_container.n8n
Terraform says it cannot find docker_container.n8n, yet docker ps shows the container running.
Root cause (this is not a bug, it is by design)
import { to = ... } blocks require the **Terraform resource address to already be declared in main.tf** — they do not auto-scaffold the resource. I had written import { to = docker_container.n8n } but had no matching resource "docker_container" "n8n" { ... } block anywhere. Import correctly errored "object not found".
Fix (stub first, then import)
# Step 1: write a stub resource first (empty config)
cat >> main.tf << 'EOF'
resource "docker_container" "n8n" {
name = "n8n-prod"
image = "n8nio/n8n:1.92.2"
# leave other fields empty; import fills them
lifecycle {
ignore_changes = all # prevent recreation after import
}
}
EOF
# Step 2: run import
terraform import docker_container.n8n $(docker ps -aqf "name=n8n-prod")
# Step 3: write actual config back into main.tf
terraform show -no-color > current_state.tf
# Manually copy the n8n block from current_state.tf into main.tf, replacing the stub
# Step 4: remove ignore_changes, plan again to confirm drift-free
# expect: "No changes. Your infrastructure matches the configuration."
This is the official workflow since TF 1.10 introduced import blocks in Jan 2026. OpenTofu 1.7 supports a stub-less flow, but HashiCorp 1.11 does not — so do not mix the two.
---
💣 Pitfall 5: No "human rollback" after apply — a careless `docker_network` edit hits staging too
Symptom
One day I tweaked modules/n8n/networks.tf to add a testing network for staging, ran apply, and discovered prod's n8n_default network was gone — every n8n container hit "network error".
Root cause
Terraform has no transaction log. Apply is a sequence of mutations; if you fail mid-stream, you can only roll back via state — but state already has the partially-mutated truth saved. **In my case, the n8n network change was clearly labeled "rebuild" in the plan output, but I skipped -auto-approve discipline and applied blind.**
Fix: 3 layers of defense
**Defense 1: Always plan first. Never -auto-approve.**
alias tf="terraform"
tf plan -out=tfplan-$(date +%Y%m%d-%H%M%S)
# review tfplan-20260716-0610 with eyes
tf apply tfplan-20260716-0610
**Defense 2: lifecycle { prevent_destroy = true } on critical resources**
resource "docker_container" "wordpress" {
name = "wordpress-prod"
image = docker_image.wordpress.image_id
lifecycle {
prevent_destroy = true # terraform destroy refuses
}
}
Defense 3: Every change on a feature branch, reviewed before merge
git checkout -b feat/add-testing-network
# modify modules/n8n/networks.tf
git push origin feat/add-testing-network
# wait for GitHub Actions plan to pass → review → merge
---
🚀 GitHub Actions auto-plan
.github/workflows/terraform-plan.yml:
name: terraform-plan
on: pull_request
jobs:
plan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.11.4
- run: terraform init -backend=false
- run: terraform validate
- run: terraform plan -var-file=envs/staging.tfvars -no-color
env:
TF_INPUT: '0'
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: tfplan
A PR triggers an automatic plan, the artifact gets uploaded. **Critical: -backend=false** — CI does not need a real backend; plan-only is enough. After merge, terraform apply runs manually on the host.
---
🛡️ Advanced: putting Ansible (6/25) behind Terraform
My final pipeline now looks like:
1. Terraform owns infrastructure primitives (containers, networks, volumes)
2. Ansible owns in-app configuration (uwsgi, PHP-FPM, certbot renewal, cron jobs)
Concrete wiring: Terraform brings containers up, exports container names/IPs via outputs.tf. Ansible's inventory.ini uses a single local host that targets the container:
# outputs.tf
output "wordpress_container_name" {
value = docker_container.wordpress.name
}
# ansible/inventory.ini
[wordpress]
localhost ansible_connection=docker ansible_docker_container=wordpress-prod
The win: Terraform owns the system; Ansible owns the app. After splitting into these layers, each side fits in 5 lines you can hand off independently.
---
📋 Verification Checklist (5-step production deploy)
| Step | Command / File | Expectation | ||
|---|---|---|---|---|
| 1 | `terraform init` | 0 error, no provider download warning | ||
| 2 | `terraform validate` | "Success! The configuration is valid." | ||
| 3 | `terraform plan -var-file=envs/prod.tfvars` | Should not contain "destroy" (unless you intended to) | ||
| 4 | `terraform apply` review tfplan | plan/apply gap < 5 min, no in-between tf edits | ||
| 5 | `terraform show -json \ | jq '.values.root_module.child_modules[].resources[] \ | select(.values.container.exit_code != 0)'` | Should output an empty array |
---
Summary: 5 pitfalls in 3 buckets
Re-reading these 5 traps, they collapse into 3 categories:
| Category | Pitfalls | Permanent fix |
|---|---|---|
| Concurrency / safety | 1, 5 | Use `flock` + `prevent_destroy` + never skip plan |
| Pinning & versions | 2 | Pin image tag + `keep_remotely` + `keep_locally` |
| Syntax / semantics | 3, 4 | Know the 1.10+ `-var-file` precedence change; stub-first for `import` blocks |
One-liner: Terraform to Ansible is what Git is to vim — the former lifts "what I did" from memory into auditable files, but only if those files stay small and single-purpose.
Next step on my list: plug Renovate into the workflow for automatic provider upgrades — weekly PR + auto plan + human review when something breaks.
---
Related Reading (already published)
👉 Join MiniMax Token Plan: AI coding acceleration for businesses
👉 Join Zhipu Coding Plan: GLM-4.6/GLM-5 coding packages, China-stable, pay-per-token unlimited
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: