Terraform IaC 单 VPS 实战
# Terraform 单 VPS IaC 实战 2026:从零到生产环境版本化的 5 个真实踩坑与修复
去年我把个人项目的 2 台 VPS(1 台跑 WordPress + 1 台跑 n8n)的配置全部塞进了一个 setup.sh 脚本,再加 ~/.bash_history 里 6 个月沉淀下来的 200 多行 ad-hoc apt install/ufw allow/certbot --nginx 命令。然后我第二次想恢复 staging 环境的时候发现:脚本里写的 ufw allow 443/tcp 后面有人手动加过一行 ufw allow 80/tcp,但仓库里没提交。结果就是 nginx 用了 Let's Encrypt HTTP-01 验证时偶然还能跑,但任何想复刻这套环境的尝试都会卡 20 分钟。
这次彻底切到 Terraform。1 台 VPS、一个博客、1 套 n8n 流水线,1 个月跑下来稳定无事故。**本文复盘 5 个真实踩坑**:state 锁跟本地 fs 冲突、provider 版本漂移把已配资源删掉、变量优先级错乱、import 块吞错导入路径、terraform apply 后没有人脑回路的「回滚」。每坑都有可复制的修复命令。
承接 6/25《Ansible 自托管实战 5 个真实坑》和 7/3《Ansible Molecule 25.x 实战》。如果你的 ansible.cfg 还在写 inventory = /etc/ansible/hosts,是时候把这层也版本化进 Git。
---
🛠️ 前置准备(Prerequisites)
我用的是这套组合(2026 年 7 月实测):
- **OS**:Ubuntu 24.04 LTS(DigitalOcean / Vultr / 自建 KVM 都可)
- **VPS 规格**:1 vCPU / 2 GB RAM / 50 GB NVMe(WordPress + n8n + Watchtower 跑完后空闲 800 MB 内存)
- **Terraform**:1.11.4(hashicorp/terraform v1.11.4,2026-05 release,IBM 收购后首个 LTS 候选)
- **Provider**:`kreuzwerker/docker` 3.0.2(同版本锁定,禁止 `~>` 浮版本)
- **后端**:本地文件系统(不是 S3,单 VPS 不需要 remote state——这点很多教程会误导)
- **CI**:GitHub Actions ubuntu-24.04 runner(自带 Terraform CLI 缓存)
验证命令:
terraform version # Terraform v1.11.4
terraform -install-autocomplete # 可选,shell 提示
git --version # 2.43+
docker --version # 26.1.5+
> ⚠️ **千万别装 OpenTofu 1.7+**——3 个月前我试过,HashiCorp 1.11 和 OpenTofu 1.7 的 import block 行为不一致(详见坑 4),回退后用 1.11 至今无问题。
---
📁 项目结构(5 步前的总览)
tf-blog/
├── main.tf # provider + backend
├── variables.tf # 全部输入变量
├── outputs.tf # 公共输出(域名/容器名/IP)
├── providers/
│ └── docker.tf # 本地 docker 资源声明
├── modules/
│ ├── wordpress/ # blog stack 封装
│ │ ├── main.tf
│ │ └── variables.tf
│ └── n8n/ # workflow 封装
├── envs/
│ ├── prod.tfvars # 生产环境变量
│ └── staging.tfvars # staging
└── .github/workflows/
└── terraform-plan.yml # PR 自动 plan
这套结构的关键是 **provider/模块/环境三层解耦**——同一份 main.tf 既能跑 terraform apply -var-file=envs/staging.tfvars 也能跑生产。
---
💣 坑 1:本地 state 文件 + 多终端冲突,导致 apply 半成功
现象
$ terraform apply
Error: Failed to save state
Error: Failed to upload state: open terraform.tfstate: permission denied
打开 terraform.tfstate 一看:上半段是 nginx 容器成功创建,下半段 ufw 规则没加进去。机器里有 2 个 shell 在跑 terraform apply——一个 git pull 后 apply,另一个还拿着 5 秒前的 state。
根因
本地文件系统做 backend 没有锁。HashiCorp 默认 terraform.tfstate 是普通 JSON,多个 apply 进程同时写会交错。
修复
3 个方案,按场景挑:
**方案 A(推荐)**:本地开发直接加 consul 或 local lock。**但单 VPS 用不到**。
**方案 B(最实用)**:用 flock 包住 apply:
flock -n /tmp/tf.lock -c "terraform apply -auto-approve"
方案 C(CI/CD 干净):换 remote backend,但不是 S3:
# main.tf
terraform {
backend "pg" { # 直接用 PostgreSQL 做 backend
conn_str = "postgres://tf_user:strong_pwd@localhost:5432/terraform_state?sslmode=disable"
}
}
我最终选了**方案 B**——单 VPS 项目没必要为 2 个并发写付 1 个 Postgres 实例的代价。flock 一行解决问题。
---
💣 坑 2:provider 版本号写 `~> 3.0.0`,升级后 docker 容器被无差别替换
现象
$ terraform plan
# docker_container.wordpress will be destroyed and recreated
# docker_container.wordpress: image_sha256 changed from "abc..." to "def..."
不是版本号改的,是我 push 了 WordPress 镜像 tag=stable,30 分钟后上游发布了一个新 sha256,Terraform 觉得「容器变了要重建」。重建 = 数据卷短暂失联 + 容器 IP 变化 + nginx upstream 重启。
根因
kreuzwerker/docker 3.0.x 的 image_id 默认每次都重算。**~> 范围选择器是必要的,但不够**——还得锁定 docker_image 的 keep_remotely 和 keep_locally。
修复(3 行救数据)
# providers/docker.tf
provider "docker" {
registry_auth {
address = "registry-1.docker.io"
}
}
resource "docker_image" "wordpress" {
name = "wordpress:6.8.2-php8.3-apache" # 钉死 tag,禁止 latest
keep_remotely = true # 不让 provider 自动拉新镜像
keep_locally = true # 不让 provider 删本地缓存
}
resource "docker_container" "wordpress" {
image = docker_image.wordpress.image_id
# ...
}
3 件套:name 用具体 tag + keep_remotely=true + keep_locally=true。从此 plan 永远是「无变化」除非你手动改 tag。
---
💣 坑 3:变量优先级错乱——`TF_VAR_*` 环境变量吃了 `.tfvars` 的值
现象
$ terraform apply -var-file=envs/prod.tfvars
# 期望:domain = techpassive.cn
# 实际:containers 域名 = localhost
debug:
$ terraform console
> var.domain
"localhost" # ❌ 应该是 techpassive.cn
排查后发现 3 个月前我在 .bashrc 里加过一行 export TF_VAR_domain=localhost 用于本地测试,**从来没删**。
优先级真相(TF 1.11 文档验证)
变量优先级从高到低:
1. CLI -var / -var-file
2. *.auto.tfvars.json
3. *.auto.tfvars
4. 环境变量 TF_VAR_*
5. terraform.tfvars / *.tfvars
6. 变量声明里的 default
**等等——CLI -var 应该最高啊?我用 -var-file 怎么被环境变量吃了?**
我去看了 changelog:TF 1.10 引入了新的 -var-file 优先级规则,**当文件名为 terraform.tfvars 时优先级在 TF_VAR_* 之上**;**当文件名为显式路径(envs/prod.tfvars)时优先级在 TF_VAR_* 之下**。这是 1.10 起的语义变化,官方 changelog 写得很清楚但容易被忽略。
修复(4 步验证)
unset TF_VAR_domain # 临时清环境变量
env | grep TF_VAR_ | xargs -I {} unset {} # 全清
echo "domain = \"techpassive.cn\"" > envs/prod.tfvars
terraform apply -var-file=envs/prod.tfvars
**永久规则**:把 unset TF_VAR_* 写进 apply 脚本最前面,或干脆用 .envrc(direnv)管理环境变量,比裸 export 不容易踩。
---
💣 坑 4:`import` 块吞错路径,已配的容器被双重管理
现象
把旧 docker-compose 里的 n8n 容器导入 Terraform 时:
$ terraform plan
# Error: Cannot import non-existent remote object
# Resource: docker_container.n8n
Terraform 说找不到 docker_container.n8n,但 docker ps 显示容器在跑。
根因(我以为是 bug,其实是设计)
import { to = ... } 块需要 **Terraform 资源地址已经在 main.tf 里声明过**——它不会自动把容器"补"进代码。我之前只写了 import { to = docker_container.n8n } 但 main.tf 里没这块 resource,import 报"找不到对象"。
修复(必须先 stub 再 import)
# Step 1: 先在 main.tf 里写空壳 resource(不带具体配置)
cat >> main.tf << 'EOF'
resource "docker_container" "n8n" {
name = "n8n-prod"
image = "n8nio/n8n:1.92.2"
# 其他字段保持空,让 import 填充
lifecycle {
ignore_changes = all # 防 import 后被重新生成
}
}
EOF
# Step 2: 执行 import
terraform import docker_container.n8n $(docker ps -aqf "name=n8n-prod")
# Step 3: terraform show 把实际配置写回 main.tf
terraform show -no-color > current_state.tf
# 人工把 current_state.tf 里的 n8n 块复制回 main.tf,替换 stub
# Step 4: 删除 ignore_changes,再 plan 一次确认无差异
# expect: "No changes. Your infrastructure matches the configuration."
这是 2026 年 1 月 TF 1.10 引入 import block 之后的官方推荐流程。如果用 OpenTofu 1.7,import block 已经支持 stub-less 流程,但 hashicorp 1.11 不支持——所以别混用。
---
💣 坑 5:apply 后没有"人脑回滚",某次误改 `docker_network` 段连 staging 都受影响
现象
某次我改 modules/n8n/networks.tf,想给 staging 加一个 testing 网络,apply 后 prod 的 n8n_default 网络消失了,n8n 容器全部 network error。
根因
Terraform 没有 transaction log。apply 是一连串 mutation,过程中失败只能靠 state 回滚,而 state 里如果没保存原状态就完了。我的 n8n 网络在 plan 阶段显示了"重建",但我图省事跳过了 plan 直接 apply。
修复:3 道防线
**防线 1:永远先 plan 再 apply,不接 -auto-approve**
alias tf="terraform"
tf plan -out=tfplan-$(date +%Y%m%d-%H%M%S)
# 人工 review tfplan-20260716-0610
tf apply tfplan-20260716-0610
**防线 2:lifecycle { prevent_destroy = true } 给关键资源加锁**
resource "docker_container" "wordpress" {
name = "wordpress-prod"
image = docker_image.wordpress.image_id
lifecycle {
prevent_destroy = true # terraform destroy 会报错
}
}
防线 3:所有改动进 feature 分支,merge 前 review
git checkout -b feat/add-testing-network
# 改 modules/n8n/networks.tf
git push origin feat/add-testing-network
# 等 GitHub Actions plan 通过后 review → merge
---
🚀 GitHub Actions 自动 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
每次开 PR 自动 plan,artifact 上传。**关键:-backend=false**——CI 不需要真后端,只跑 plan 足够了。merge 后手动在本地 terraform apply。
---
🛡️ 进阶:把 6/25 Ansible 接到 Terraform 之后
我现在的最终流水线:
1. Terraform 负责基础设施工件(容器、网络、卷)
2. Ansible 负责容器内配置(uwsgi、PHP-FPM、certbot 证书续期、cron jobs)
具体做法:Terraform 把容器跑起来后,把容器名/IP 输出到 outputs.tf,Ansible 的 inventory.ini 用 local 一个 host,执行 ansible-playbook -i inventory.ini setup.yml 只配容器内。
# outputs.tf
output "wordpress_container_name" {
value = docker_container.wordpress.name
}
# ansible/inventory.ini
[wordpress]
localhost ansible_connection=docker ansible_docker_container=wordpress-prod
这样的好处:Terraform 管 system,Ansible 管 app,分层之后两边各自 5 行内就能迁移。
一个具体场景:跑 WordPress 怎么拆
Terraform 跑(这层每月动 1 次):
# modules/wordpress/main.tf
resource "docker_network" "blog" {
name = "blog-net"
}
resource "docker_volume" "wordpress_data" {
name = "wordpress-data"
}
resource "docker_container" "wordpress" {
name = "wordpress-prod"
image = docker_image.wordpress.image_id
networks_advanced {
name = docker_network.blog.name
}
mounts {
source = docker_volume.wordpress_data.name
target = "/var/www/html"
}
env = [
"WORDPRESS_DB_HOST=mysql",
"WORDPRESS_DB_USER=wp",
"WORDPRESS_DB_PASSWORD_FILE=/run/secrets/wp_db_pwd",
]
lifecycle {
prevent_destroy = true
}
}
Ansible 跑(这层每周动多次):
- hosts: wordpress
# ansible/setup.yml
tasks:
- name: 装 wp-cli
apt:
name: wp-cli
state: present
- name: 配 crontab 自动备份
cron:
name: wp-backup
minute: "0 3 * * *"
job: "wp db export /tmp/backup.sql && tar czf /tmp/wp-content.tar.gz /var/www/html/wp-content"
- name: 装 Redis Object Cache Pro 插件
command: wp plugin install redis-cache --activate
这两层在 PR 里清晰分开:改 Terraform 是基础设施变更,走 feat/ 分支 + 2 人 review;改 Ansible 是配置变更,1 人 review 即可。我目前是单 VPS 不需要这么严格,但单 VPS 迁移到 K8s 或多 VPS 时这套分层是直接套用的。
为什么 state 不要进 Git
我之前纠结了一下要不要把 terraform.tfstate 进仓库。最后没进,原因如下:
1. state 文件含敏感信息:terraform.tfstate 默认明文 JSON,可能含数据库密码、API token、容器名映射。一旦泄露给外部协作方等同泄露基础设施全貌。
2. 并发写入会冲突:state 是变更日志不是"配置文件",多人同时改会产生合并冲突,无法像代码那样解冲突。
3. state 大小会膨胀:跑了 1 年下来我的 state 从 12 KB 涨到 78 KB(容器 + 网络 + 输出),git diff 体验极差。
替代方案用 HashiCorp 官方推荐的 Terraform Cloud 或者自托管 backend(前面坑 1 提到过 `pg` backend)。单 VPS 项目用本地 state + `flock` 已经够用,不要过早优化。我自己的最终方案是:state 完全不入库,本地用 `flock` + CI 用 `-backend=false` 只做 plan。
---
`terraform plan` 输出该怎么读
第一次跑 plan 的人都会被几百行 + create、- destroy、~ update in-place 吓到。一个简单的判断规则:
- **`+ create`**(绿色加号):将创建新资源。安全。
- **`- destroy`**(红色减号):将删除资源。**重点检查**——是否你故意改的?
- **`~ update in-place`**(黄色波浪号):就地更新。确认具体改的是哪个字段(plan 里有 `name = "old" -> "new"` 这种展开),看是不是符合预期。
- **`-/+ destroy and then create new`**(红色 + 创建):Terraform 决定不能就地更新要重建。**风险等级同 destroy**——确认数据卷是否还需要、是否会被迁移。
# 一行命令过滤掉「无需手动 review 的变更」,只留 destroy 类
terraform plan -var-file=envs/prod.tfvars | grep -E "^[ ]*(# |-|\+)" | grep -v "no changes" | grep -E "destroy"
这行就是我 PR review 时跑的——只看有没有 destroy。
---
📋 验证清单(5 步生产部署)
| 步骤 | 命令 / 文件 | 期望 | ||
|---|---|---|---|---|
| 1 | `terraform init` | 0 error,无 provider download warning | ||
| 2 | `terraform validate` | "Success! The configuration is valid." | ||
| 3 | `terraform plan -var-file=envs/prod.tfvars` | 不应有 "destroy" 字样(除非你故意改) | ||
| 4 | `terraform apply` 自审 tfplan | plan/apply 间隔 < 5 分钟,期间没改 tf | ||
| 5 | `terraform show -json \ | jq '.values.root_module.child_modules[].resources[] \ | select(.values.container.exit_code != 0)'` | 应输出空数组 |
---
总结:5 坑的根因结构
回看这 5 个坑,可以归到 3 类:
| 类别 | 涉及坑 | 治本 |
|---|---|---|
| 协作并发 | 坑 1、坑 5 | 用 flock + prevent_destroy + 永远不跳 plan |
| 锁定与版本 | 坑 2 | image tag 钉死 + keep_remotely + keep_locally |
| 语法与语义 | 坑 3、坑 4 | 1.10 起的 -var-file 优先级变化必须知晓;import 块要先写 stub |
一句话:Terraform 之于 Ansible,相当于 Git 之于 vim——前者把后者的"做了什么"从历史记忆搬到可审计文件,但前提是你得让这些文件保持自己足够小、足够单一职责。
下一步计划:把 Renovate 也接进来自动升级 provider 版本,每周一 PR + 自动 plan + 失败就人工介入。
---
相关阅读(已发布)
👉 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: