Terraform — Backend de Estado y Migración¶
El problema¶
Terraform no le pregunta al proveedor cloud qué gestiona: se lo pregunta al state. Ese fichero es el único sitio donde vive la correspondencia entre el aws_instance.web de tu código y el i-0abc123… real. Si el state desaparece, Terraform no cree que la instancia esté rota: cree que no existe y la vuelve a crear. Un apply sin state es un apply sobre una infraestructura fantasma.
Con un solo operador y un terraform.tfstate local eso casi nunca duele. En cuanto hay dos personas —o una persona y un pipeline— aparecen los tres fallos clásicos: dos apply simultáneos que se pisan y dejan el state describiendo una realidad que ya no existe; un terraform.tfstate commiteado a Git con la contraseña de la base de datos en texto plano; y el portátil que se formatea llevándose el único registro de qué había en producción.
El backend remoto resuelve los tres a la vez: almacenamiento compartido, cifrado y con locking. Esta página cubre cómo montarlo, cómo migrar sin recrear nada, y las operaciones de mantenimiento —state mv, import, -refresh-only— que hacen falta cuando la realidad y el state divergen. Todo ello es común a Terraform y a OpenTofu, con tofu en lugar de terraform; en funcionalidades recientes, comprueba la documentación de tu versión antes de copiar un flag de cualquier guía, esta incluida.
📋 Tabla de Contenidos¶
- Qué guarda el state y por qué es sensible
- Local frente a remoto
- Backend S3 con locking
- Política IAM mínima
- Locking y locks huérfanos
- migrate-state frente a reconfigure
- Workspaces frente a directorios separados
- Operar sobre el state
- Importar recursos preexistentes
- Drift y refresh-only
- State en CI: OIDC en lugar de claves
- Backup y versionado
- Troubleshooting
- Buenas prácticas
- Referencias
Qué guarda el state y por qué es sensible¶
El state es un JSON con el mapeo entre direcciones de recurso (module.db.aws_db_instance.main) e identificadores reales del proveedor, más una copia de los atributos leídos en el último refresco y las dependencias entre recursos. De ahí salen tres cosas: qué crear, qué destruir y en qué orden.
La consecuencia incómoda es que guarda los atributos completos, incluidos los sensibles. Una contraseña de RDS, una private_key generada por el provider TLS, el contenido de un kubernetes_secret: todo eso está en el state en claro. sensitive = true afecta a lo que se imprime en pantalla, no a lo que se escribe en el fichero. Compruébalo tú mismo con terraform state pull | jq '.resources[].instances[].attributes'.
El state es un secreto, trátalo como tal
- Nunca en Git:
*.tfstate,*.tfstate.*y.terraform/al.gitignoredesde el primer commit. - Cifrado en reposo y acceso restringido a quien pueda hacer
apply: leer el state equivale a leer todos los secretos que toca ese código. - Si un secreto ha pasado por el state, considéralo expuesto ante quien haya tenido acceso al bucket. Rótalo.
- Referenciar secretos desde un gestor (Vault, Secrets Manager) reduce la superficie, aunque el valor leído siga acabando en el state. Ver gestión de secretos.
Local frente a remoto¶
| Local (por defecto) | Remoto | |
|---|---|---|
| Ubicación | terraform.tfstate en el directorio |
Bucket o servicio compartido |
| Colaboración | Ninguna: el fichero es tuyo | Equipo y CI ven lo mismo |
| Locking | No existe | Sí, si el backend lo soporta |
| Cifrado y backup | Los de tu disco | SSE/KMS y versionado del bucket |
| Riesgo de fuga | Alto (commit accidental) | Controlado por IAM |
Local es razonable para un laboratorio de un día. Para cualquier cosa que dos personas puedan tocar, o que un pipeline vaya a aplicar, remoto.
Backend S3 con locking¶
El bloque backend va dentro de terraform { } y no admite variables ni interpolación: sus valores deben ser literales o venir por -backend-config. Es la limitación que más sorprende al llegar del resto de HCL.
terraform {
required_version = ">= 1.5"
backend "s3" {
bucket = "acme-tfstate-prod"
key = "plataforma/red/terraform.tfstate"
region = "eu-west-1"
encrypt = true
kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/abcd-1234"
dynamodb_table = "acme-tfstate-locks"
}
}
key es la ruta dentro del bucket: un key distinto por componente y por entorno, que es lo que separa de verdad los states. encrypt = true fuerza cifrado en reposo y kms_key_id añade control de acceso a la clave, auditable en CloudTrail. La tabla de locks se crea una vez, en una configuración de bootstrap aparte —el bucket y la tabla no pueden vivir en el state que ellos mismos guardan—:
resource "aws_dynamodb_table" "locks" {
name = "acme-tfstate-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
El atributo debe llamarse LockID y ser la clave de partición; con cualquier otro nombre falla la adquisición del lock. Una sola tabla sirve para todos los states de la cuenta: la fila se identifica por la ruta del state.
Locking nativo de S3: depende de tu versión — no verificado aquí
Versiones recientes del backend S3 admiten locking sin DynamoDB, mediante un objeto .tflock junto al state (argumento use_lockfile), y dynamodb_table queda como opción heredada. No des por hecho cuál aplica a tu instalación: consulta la documentación del backend S3 de la versión exacta que uses —y de OpenTofu, si es tu caso— y confírmalo con un terraform init. Lo innegociable es no quedarte sin ningún mecanismo de locking.
Política IAM mínima¶
Permisos justos para operar sobre un state, sin acceso al resto del bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::acme-tfstate-prod",
"Condition": { "StringLike": { "s3:prefix": ["plataforma/red/*"] } }
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-tfstate-prod/plataforma/red/*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
"Resource": "arn:aws:dynamodb:eu-west-1:123456789012:table/acme-tfstate-locks"
}
]
}
- El bloque de DynamoDB sobra con locking por fichero: el lock es entonces un objeto más bajo el mismo prefijo.
- Con
kms_key_idhay que añadirkms:Encrypt,kms:Decryptykms:GenerateDataKeysobre el ARN de la clave, o todo falla con unAccessDeniedque no menciona KMS por ninguna parte. - Un rol de solo lectura (
s3:GetObjectys3:ListBucket) para revisar planes es la mejor separación de privilegio de esta página: mucha gente necesita ver elplan, muy poca necesita aplicarlo.
Locking y locks huérfanos¶
Antes de escribir, Terraform adquiere un lock; al terminar, lo libera. Si el proceso muere entre medias —Ctrl+C agresivo, runner cancelado, red caída— el lock se queda y el siguiente apply falla con Error: Error acquiring the state lock, seguido de un bloque Lock Info con el ID, el Operation, el Who y el Created. Ese bloque es información, no solo un error: dice si hay alguien trabajando de verdad o si es un resto de hace tres horas.
terraform force-unlock 7c1e2b58-...
Comprueba antes de forzar
force-unlock no cancela ninguna operación: solo borra el lock. Si el apply original sigue vivo en otra máquina, acabas con dos escrituras concurrentes y un state corrupto, que es exactamente el daño que el lock evitaba.
Protocolo: mirar Who y Created, preguntar en el canal del equipo, revisar si hay un job en marcha. Solo entonces desbloquear. Existe un flag para saltarse la confirmación interactiva; no lo pongas en un script desatendido.
Para lecturas puntuales donde el lock estorba —y solo lecturas— la mayoría de comandos aceptan -lock=false. Con plan, nunca con apply.
migrate-state frente a reconfigure¶
Es la confusión que más states rompe. Al cambiar el bloque backend, terraform init ofrece dos caminos incompatibles:
| Flag | Qué hace con el state existente | Cuándo |
|---|---|---|
-migrate-state |
Lo copia al nuevo backend | Mover el state de sitio conservando la infraestructura |
-reconfigure |
Lo ignora: empieza limpio en el nuevo backend | El backend destino ya tiene el state correcto |
Traducido: -migrate-state conserva, -reconfigure olvida. Si ejecutas -reconfigure cuando querías migrar, el backend nuevo arranca vacío, el siguiente plan propone crear toda la infraestructura desde cero y el state antiguo se queda huérfano donde estaba. Es recuperable —el fichero original no se borra— pero solo si reconoces el síntoma a tiempo.
terraform state pull > backup-$(date +%F).tfstate # 1. copia de seguridad, siempre
# 2. añadir el bloque backend "s3" a la configuración
terraform init -migrate-state # 3. migrar
terraform state list # 4. verificar
terraform plan # debe decir "No changes"
Ese plan limpio del paso 4 es la única prueba válida de que la migración fue bien. Si propone crear o destruir algo, para: el state migrado no corresponde con la infraestructura.
Cuando el backend se parametriza por entorno, los valores van en ficheros .hcl sueltos (bucket, key, region, una clave por línea) porque el bloque no admite variables, y se cargan con -backend-config:
terraform init -reconfigure -backend-config=backends/prod.hcl
Aquí -reconfigure es lo correcto: no mueves un state, apuntas el directorio de trabajo a otro que ya existe. -migrate-state intentaría copiar el state de staging encima del de prod.
Workspaces frente a directorios separados¶
Los workspaces (terraform workspace new staging, select, list, show) crean varios states para una misma configuración. Lo que un workspace sí aísla es el state. Lo que no aísla:
- Las credenciales. El proveedor se configura igual en todos los workspaces, y las variables
AWS_*que tengas cargadas valen para cualquiera. Unapplyen el workspace equivocado ejecuta contra la cuenta que estuviera activa, sin barrera alguna. - El código. Un cambio en el
.tfafecta a todos a la vez: no hay promoción gradual de staging a prod. - El backend. Mismo bucket y mismos permisos: quien lee un workspace los lee todos.
- El despiste. Nada en el prompt dice en qué workspace estás.
La alternativa es un directorio por entorno (envs/dev, envs/staging, envs/prod), cada uno con su backend, su provider y sus tfvars, compartiendo código a través de módulos en modules/. Regla práctica: workspaces para variaciones efímeras del mismo entorno —una rama de pruebas, un despliegue temporal—, directorios separados para entornos con fronteras de seguridad distintas. Si dev y prod están en cuentas separadas —y deberían—, los workspaces no son la herramienta.
Operar sobre el state¶
Estos comandos modifican el inventario, no la infraestructura: Terraform no toca nada en el proveedor, cambia lo que cree que existe.
terraform state list # todo lo que gestiona
terraform state show aws_instance.web # atributos de un recurso
terraform state pull > snapshot.tfstate # descargar el state completo
terraform state mv aws_instance.web module.frontend.aws_instance.this
state mv renombra o mueve una dirección: es lo que evita destruir y recrear al refactorizar. Si renombras en el .tf sin hacer el mv, Terraform ve desaparecer la dirección antigua y aparecer la nueva, y planifica destruir y crear. En una base de datos, eso es un incidente.
state rm no destruye, y ese es el problema
terraform state rm aws_instance.web olvida el recurso: sigue existiendo y facturando en el proveedor, pero Terraform deja de gestionarlo. Nadie lo destruirá, nadie lo actualizará y no aparecerá en ningún plan. Es la forma más limpia de crear recursos huérfanos que nadie recuerda seis meses después.
Uso legítimo: sacar un recurso de un state para llevarlo a otro. En ese caso haz el import en el destino antes del rm en el origen, y anota el ID.
Dos precauciones que ahorran disgustos: terraform state pull > antes.tfstate antes de cualquier mv o rm, y un terraform plan después, que debe salir sin cambios.
Importar recursos preexistentes¶
Cuando la infraestructura existe pero el state no la conoce —creada a mano, heredada, migrada de otra herramienta—, import la adopta. Escribe primero el bloque de recurso en el .tf, aunque sea incompleto, y después asocia la dirección de Terraform con el ID real del proveedor:
terraform import aws_instance.web i-0abc123def456
terraform plan # ¿qué diferencias hay entre el código y la realidad?
Ese plan es el trabajo de verdad. import rellena el state con los atributos reales pero no escribe tu configuración: hasta que el .tf describa el recurso tal como es, el plan propondrá cambios. Se itera —ajustar HCL, plan, repetir— hasta llegar a "No changes".
El formato del ID depende del proveedor y del recurso: una instancia EC2 usa i-…, una regla de security group usa una cadena compuesta, un recurso de Kubernetes usa namespace/nombre. Está documentado en la sección Import de cada recurso; no lo adivines.
Bloques import declarativos — verifica tu versión
Las versiones recientes de Terraform admiten bloques import en el propio HCL, planificables y revisables en un PR en lugar de ejecutados a mano, con la opción de generar un esqueleto de configuración. Es más cómodo para importaciones masivas, pero confirma disponibilidad y sintaxis exacta en la documentación de tu versión antes de meterlo en un pipeline.
Drift y refresh-only¶
Drift es la diferencia entre lo que dice el state y lo que hay realmente en el proveedor: alguien tocó algo por consola, un autoscaler cambió una capacidad, un proceso externo añadió una etiqueta.
terraform plan -refresh-only # solo compara, no propone cambios de configuración
terraform apply -refresh-only # actualiza el state con la realidad, sin tocar recursos
terraform plan -refresh=false # lo contrario: no consulta al proveedor
La diferencia con un plan normal importa: plan mezcla dos cosas —lo que cambió fuera y lo que quieres cambiar tú— en una salida difícil de leer. -refresh-only aísla la primera pregunta: ¿qué ha cambiado sin mi permiso? El comando suelto terraform refresh está deprecado en favor de apply -refresh-only, precisamente porque el antiguo actualizaba el state sin mostrar antes qué iba a cambiar.
-refresh=false acelera el plan en configuraciones grandes a costa de trabajar con datos posiblemente obsoletos: útil en un bucle de desarrollo rápido, mala idea antes de un apply en producción. Y un job periódico que ejecute plan -refresh-only y avise si hay diferencias es de las alertas más baratas y útiles que puedes añadir a un entorno con IaC; ver GitHub Actions para el andamiaje.
State en CI: OIDC en lugar de claves¶
Guardar un AWS_ACCESS_KEY_ID de larga duración en los secretos del repositorio significa tener una credencial permanente, con permisos de escritura sobre el state y sobre la infraestructura, en un sitio que mucha gente puede leer o exfiltrar desde un workflow modificado. Con OIDC, el proveedor de CI emite un token de corta duración que la nube valida contra un proveedor de identidad: no hay clave que rotar ni que filtrar.
permissions:
id-token: write # imprescindible para pedir el token OIDC
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci-plan
aws-region: eu-west-1
- run: terraform init
- run: terraform plan -out=tfplan
La pieza crítica está del lado de la nube: la trust policy del rol debe limitar quién puede asumirlo —repositorio y, a ser posible, rama o entorno concretos—. Un rol que confía en cualquier repositorio de la organización deja el apply de producción al alcance de cualquiera con permisos de workflow. Usa dos roles: terraform-ci-plan con acceso de lectura al state, asumible desde cualquier PR, y terraform-ci-apply con escritura, asumible solo desde la rama principal y con aprobación. Así un PR externo genera un plan revisable sin poder escribir el state. Detalles en seguridad en IaC y secretos en GitOps.
El plan también es sensible
Un fichero de plan (-out=tfplan) contiene los valores que se van a escribir, secretos incluidos. No lo publiques como artefacto accesible ni pegues su salida completa en un comentario público del PR.
Backup y versionado¶
El versionado del bucket convierte un state rm desafortunado en un susto de cinco minutos: actívalo con aws_s3_bucket_versioning y status = "Enabled" sobre el bucket de state. Recuperar consiste entonces en sacar la versión anterior del objeto y subirla con terraform state push. Ese comando sobrescribe el state remoto: descarga primero el actual, compruébalo, y solo entonces empuja.
- Snapshot antes de cada operación manual.
terraform state pull > antes.tfstatecuesta un segundo. - Bloquear el acceso público del bucket explícitamente, sin confiar en el valor por defecto.
- Object Lock o política de retención si el requisito es que nadie, ni siquiera un administrador, pueda borrar el histórico.
- Bucket en una cuenta distinta a la infraestructura que gestiona: comprometer producción no debería llevarse por delante el registro de qué había en ella. Mismo razonamiento que en la estrategia 3-2-1.
Troubleshooting¶
| Síntoma | Causa | Arreglo |
|---|---|---|
Error acquiring the state lock |
Lock huérfano de un proceso muerto | Verificar Who/Created y terraform force-unlock <ID> |
Backend configuration changed |
Cambió el bloque backend |
init -migrate-state (copiar) o -reconfigure (apuntar a otro) |
El plan propone crear todo tras migrar |
Se usó -reconfigure en vez de -migrate-state |
Restaurar el state antiguo y repetir con -migrate-state |
Resource already exists al aplicar |
El recurso existe pero no está en el state | terraform import con el ID real |
El plan destruye y recrea tras renombrar |
Cambió la dirección sin state mv |
terraform state mv <vieja> <nueva> |
| Cambios que nadie ha pedido | Drift: modificación manual fuera de Terraform | plan -refresh-only para verlo y decidir |
AccessDenied sin mención a KMS |
Faltan permisos sobre la clave de cifrado | Añadir kms:Decrypt, kms:Encrypt, kms:GenerateDataKey al rol |
| Se aplicó en el entorno equivocado | Workspace o -backend-config incorrecto |
terraform workspace show antes de cada apply |
Cuando el state parece inconsistente y no sabes por dónde empezar, terraform state pull | jq '.serial, .lineage' da la pista: serial sube con cada escritura y lineage identifica el linaje. Dos states con lineage distinto no son versiones del mismo, y Terraform se negará a mezclarlos; es la señal de que en algún momento se creó un state nuevo en lugar de migrar el existente.
Buenas prácticas¶
- Backend remoto con locking desde el primer día. Migrar después funciona, pero el incidente que te convence de migrar es caro.
- Un state por componente y entorno, no un state monolítico: reduce el radio de impacto y el tiempo de
plan. .gitignorecon*.tfstate*y.terraform/antes del primer commit, no después del primer susto.- Snapshot antes de cualquier
state mv,rmopush. Son las tres operaciones sin deshacer. planlimpio como criterio de éxito de toda migración o importación: "No changes" o no has terminado.- OIDC en CI, con roles separados de lectura y escritura.
- Detección de drift periódica, aunque solo sea un aviso semanal.