← Back to Home

Terraform IaC Hands-On

IaCTerraformDevOpsAnsible successorMolecule follow-upinfrastructure as codeversioned

# 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):

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)

StepCommand / FileExpectation
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 tfplanplan/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:

CategoryPitfallsPermanent fix
Concurrency / safety1, 5Use `flock` + `prevent_destroy` + never skip plan
Pinning & versions2Pin image tag + `keep_remotely` + `keep_locally`
Syntax / semantics3, 4Know 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:

☁️ DigitalOcean Cloud ⚡ Vultr VPS ⭐ MiniMax Token Plan 🧩 Zhipu Coding Plan 🎁 Zhipu 20M Tokens Gift 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books
← Back to Home