Voltar para o Blog

Lock de state do Terraform no S3 sem DynamoDB (use_lockfile)

Como travar o state do Terraform usando apenas o Amazon S3, com bucket endurecido, IAM de privilégio mínimo, teste de concorrência e migração segura a partir do DynamoDB.

Lock de state do Terraform no S3 sem DynamoDB (use_lockfile)

Se você ainda mantém uma tabela DynamoDB só para travar o state do Terraform, dá para aposentá-la. Desde o Terraform 1.10 o backend s3 consegue fazer o lock usando o próprio bucket, e no 1.11 esse recurso virou estável, com a opção use_lockfile = true. Ao mesmo tempo, a HashiCorp marcou como obsoletos os argumentos de lock via DynamoDB, e a documentação atual avisa que eles serão removidos em uma versão minor futura.

Neste tutorial vamos montar, do zero e de forma reproduzível, um bucket de state endurecido (versionamento, criptografia, bloqueio de acesso público e política que exige TLS), um projeto de exemplo que usa o lock nativo do S3, uma policy IAM de privilégio mínimo que inclui o arquivo .tflock e um pequeno script em TypeScript que audita locks esquecidos. No fim, mostramos como migrar um projeto que hoje usa dynamodb_table sem perder o state e sem deixar uma janela sem proteção.

O problema: dois recursos para cuidar de um arquivo

O state é o mapa entre o seu código e o que existe de verdade na conta AWS. Quando duas pessoas, ou dois jobs de CI, rodam terraform apply ao mesmo tempo sobre o mesmo state, uma escrita pode sobrescrever a outra e o Terraform passa a "esquecer" recursos que continuam existindo e gerando custo. O lock existe para impedir exatamente isso: antes de qualquer operação que possa gravar o state, o Terraform tenta adquirir uma trava e desiste se outra execução já a detém.

Durante anos, o backend s3 não conseguia fazer essa trava sozinho, porque o S3 não oferecia uma escrita condicional do tipo "crie este objeto apenas se ele ainda não existir". A solução padrão era criar uma tabela DynamoDB com a chave de partição LockID e apontar o argumento dynamodb_table para ela. Funcionava bem, mas obrigava cada time a provisionar, proteger e dar permissões em um segundo serviço apenas para guardar alguns bytes de metadados.

Essa duplicidade gera atrito real. A policy IAM do pipeline precisa de permissões em S3 e em DynamoDB, o bootstrap ganha mais um recurso, auditorias precisam olhar dois lugares e, em contas com muitos projetos, a tabela de lock vira um ponto que ninguém lembra quem criou. Quando alguém apaga a tabela por engano, todos os projetos que dependem dela param de conseguir rodar plan.

A virada veio quando o Amazon S3 passou a suportar escritas condicionais. Com o cabeçalho If-None-Match: *, um PutObject só é aceito se ainda não houver objeto com aquela chave. No código-fonte do backend s3 do Terraform 1.17.1 dá para ver exatamente isso: a função que adquire o lock grava um arquivo JSON com as informações da trava usando IfNoneMatch: "*". Se outro processo já criou o arquivo, a escrita falha e o Terraform informa que o state está travado.

O que você vai ter ao final

Ao terminar, você terá dois diretórios Terraform independentes. O primeiro, bootstrap, cria o bucket de state com as proteções recomendadas e uma policy IAM pronta para anexar à role do CI. O segundo, app, é um projeto de exemplo que guarda o state nesse bucket com use_lockfile = true e cria um único parâmetro no SSM Parameter Store, só para termos algo a aplicar.

Você também terá um script audit-locks.ts que lista arquivos .tflock mais antigos que um limite configurável e mostra quem criou cada trava, com qual operação e com qual versão do Terraform. Ele é somente leitura de propósito: não apaga nada, apenas ajuda a decidir quando faz sentido usar terraform force-unlock.

Pré-requisitos e versões

As versões abaixo foram conferidas em 29 de outubro de 2026. A versão mais recente do Terraform CLI publicada pela HashiCorp era a 1.17.1, e a do provider hashicorp/aws no Terraform Registry era a 6.71.0. Para o script, o pacote @aws-sdk/client-s3 estava na 3.1159.0, o typescript na 7.0.2 e o tsx na 4.23.15.

  • Terraform CLI 1.11 ou superior (o exemplo foi validado com 1.17.1)
  • Provider hashicorp/aws na série 6.x (restrição ~> 6.0)
  • AWS CLI v2 configurado com um perfil de uma conta de testes
  • Node.js 22 ou superior para o script de auditoria
  • Permissão para criar bucket S3 e policy IAM na conta de testes

O mínimo de 1.11 não é capricho. O lock nativo apareceu no 1.10, mas foi no 1.11 que a HashiCorp o declarou estável e passou a recomendar a migração. Usar 1.10 em produção significa depender de um recurso que, naquele momento, ainda estava amadurecendo; o próprio changelog do 1.10 registra correções relacionadas, como a falha ao gravar o lock em buckets com Object Lock habilitado.

Sobre o Node.js, um detalhe prático: ao rodar o script com Node 20, o próprio AWS SDK v3 emitiu um aviso de que as versões publicadas a partir da primeira semana de janeiro de 2027 vão exigir Node 22 ou superior. Por isso o tutorial já adota Node 22 como mínimo, evitando que o script quebre na próxima atualização de dependências.

Arquitetura e fluxo do lock

O fluxo é direto. Ao rodar terraform plan ou apply, o Terraform tenta criar o objeto <key>.tflock ao lado do state, com escrita condicional. Se conseguir, lê o state, calcula o plano e, no apply, grava o novo state. Ao terminar, lê o arquivo de lock, confere se o ID bate com o da trava que ele mesmo criou e só então apaga o objeto. Se a criação inicial falhar porque o objeto já existe, ele lê o conteúdo para mostrar quem está segurando a trava.

Com o state em projetos/app-demo/terraform.tfstate, o lock fica em projetos/app-demo/terraform.tfstate.tflock. Essa convenção de nome é importante para a policy IAM: a documentação do backend diz que, com use_lockfile ativo, o Terraform precisa de s3:GetObject, s3:PutObject e s3:DeleteObject sobre o arquivo .tflock, enquanto o state em si não precisa de DeleteObject, já que o Terraform nunca apaga o state.

O conteúdo do .tflock é o JSON da estrutura LockInfo do Terraform, com os campos ID, Operation, Info, Who, Version, Created e Path. É isso que permite ao script de auditoria mostrar informações úteis sem precisar de nenhum serviço extra.

Estrutura de diretórios

tf-state-lock-s3/
├── bootstrap/
│   ├── versions.tf
│   ├── variables.tf
│   ├── main.tf
│   ├── outputs.tf
│   └── terraform.tfvars.example
├── app/
│   ├── versions.tf
│   ├── backend.tf
│   ├── backend.hcl.example
│   ├── variables.tf
│   ├── main.tf
│   └── terraform.tfvars.example
└── scripts/
    ├── package.json
    ├── tsconfig.json
    ├── .env.example
    └── src/
        └── audit-locks.ts

A separação entre bootstrap e app resolve o problema do ovo e da galinha. O bucket que guarda o state precisa existir antes de qualquer projeto configurar o backend, então ele é criado por um diretório que, na primeira execução, usa state local. Depois, se quiser, você pode migrar o state do próprio bootstrap para o bucket, mas isso é opcional e não muda nada no restante do tutorial.

Preparando o ambiente

Crie as pastas, configure o perfil da AWS e confirme que está na conta certa antes de criar qualquer recurso. Use sempre uma conta de testes na primeira vez: o bucket é criado com prevent_destroy, justamente para que um destroy acidental não apague o histórico de states.

mkdir -p tf-state-lock-s3/{bootstrap,app,scripts/src}
cd tf-state-lock-s3
export AWS_PROFILE=minha-conta-de-testes
aws sts get-caller-identity
terraform version

O nome do bucket precisa ser globalmente único. Uma convenção que evita colisões é combinar o nome da empresa, a finalidade e o ID da conta, como no exemplo minha-empresa-terraform-state-123456789012. Troque pelos seus valores nos arquivos .example e copie-os sem o sufixo; os arquivos reais não devem ir para o repositório se contiverem informações que você prefere não expor.

Passo 1: o bucket de state endurecido

Comece pelas versões. Fixar required_version em 1.11 ou superior garante que ninguém do time tente usar o projeto com um Terraform que não entende o lock nativo de forma estável.

bootstrap/versions.tf

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

As variáveis deixam explícito o que muda entre contas: região, nome do bucket, prefixo do time e por quantos dias manter versões antigas do state.

bootstrap/variables.tf

variable "aws_region" {
  description = "Região AWS onde o bucket de state será criado."
  type        = string
}

variable "state_bucket_name" {
  description = "Nome globalmente único do bucket S3 que guardará os arquivos de state."
  type        = string
}

variable "state_key_prefix" {
  description = "Prefixo (pasta lógica) onde os states deste time ficam dentro do bucket."
  type        = string
  default     = "projetos"
}

variable "noncurrent_version_retention_days" {
  description = "Quantos dias manter versões antigas do state antes de expirá-las."
  type        = number
  default     = 90
}

O arquivo principal concentra as proteções. O versionamento é a recomendação mais enfática da documentação do backend, porque permite recuperar o state depois de uma exclusão acidental ou de um erro humano. A regra de ciclo de vida expira apenas versões não correntes depois do prazo escolhido, para que o histórico não cresça indefinidamente.

bootstrap/main.tf

resource "aws_s3_bucket" "state" {
  bucket = var.state_bucket_name

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_ownership_controls" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "state" {
  bucket = aws_s3_bucket.state.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "state" {
  bucket = aws_s3_bucket.state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "state" {
  bucket = aws_s3_bucket.state.id

  rule {
    id     = "expirar-versoes-antigas-do-state"
    status = "Enabled"

    filter {}

    noncurrent_version_expiration {
      noncurrent_days = var.noncurrent_version_retention_days
    }

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }

  depends_on = [aws_s3_bucket_versioning.state]
}

data "aws_iam_policy_document" "tls_only" {
  statement {
    sid     = "NegarTrafegoSemTLS"
    effect  = "Deny"
    actions = ["s3:*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    resources = [
      aws_s3_bucket.state.arn,
      "${aws_s3_bucket.state.arn}/*",
    ]

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "state" {
  bucket = aws_s3_bucket.state.id
  policy = data.aws_iam_policy_document.tls_only.json

  depends_on = [aws_s3_bucket_public_access_block.state]
}

data "aws_iam_policy_document" "state_access" {
  statement {
    sid       = "ListarApenasOPrefixoDoTime"
    effect    = "Allow"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.state.arn]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values   = ["${var.state_key_prefix}/*"]
    }
  }

  statement {
    sid     = "LerEGravarState"
    effect  = "Allow"
    actions = ["s3:GetObject", "s3:PutObject"]
    resources = [
      "${aws_s3_bucket.state.arn}/${var.state_key_prefix}/*.tfstate",
    ]
  }

  statement {
    sid     = "GerenciarArquivoDeLock"
    effect  = "Allow"
    actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
    resources = [
      "${aws_s3_bucket.state.arn}/${var.state_key_prefix}/*.tfstate.tflock",
    ]
  }
}

resource "aws_iam_policy" "state_access" {
  name        = "terraform-state-${var.state_key_prefix}"
  description = "Acesso minimo ao state remoto e ao lock nativo do S3 para o prefixo ${var.state_key_prefix}."
  policy      = data.aws_iam_policy_document.state_access.json
}

Vale explicar algumas escolhas. O BucketOwnerEnforced desativa ACLs e faz com que a conta dona do bucket seja dona de todos os objetos, o que simplifica permissões. A policy de bucket nega qualquer acesso sem TLS, porque o state costuma conter dados sensíveis, como senhas geradas e endpoints internos. A criptografia padrão usa SSE-S3; se você precisar de chaves gerenciadas por você, troque por SSE-KMS e lembre que a documentação exige kms:Encrypt, kms:Decrypt e kms:GenerateDataKey para quem usa o backend.

A policy state_access segue o modelo da documentação oficial: listar o bucket restrito ao prefixo, ler e gravar o state e ler, gravar e apagar o arquivo de lock. Se você usar workspaces, os states ficam sob o workspace_key_prefix (por padrão env:), e a documentação acrescenta s3:DeleteObject sobre os states desses workspaces; nesse caso, ajuste os recursos da policy.

bootstrap/outputs.tf

output "state_bucket_name" {
  description = "Nome do bucket para usar no bloco backend \"s3\"."
  value       = aws_s3_bucket.state.bucket
}

output "state_access_policy_arn" {
  description = "ARN da policy IAM para anexar à role de CI ou ao grupo do time."
  value       = aws_iam_policy.state_access.arn
}

bootstrap/terraform.tfvars.example

aws_region        = "sa-east-1"
state_bucket_name = "minha-empresa-terraform-state-123456789012"
state_key_prefix  = "projetos"

Agora aplique o bootstrap. Revise o plano com atenção, porque é a única etapa que cria recursos de longa duração.

cd bootstrap
cp terraform.tfvars.example terraform.tfvars
terraform init
terraform plan -out=bootstrap.tfplan
terraform apply bootstrap.tfplan
terraform output
cd ..

Passo 2: o projeto que usa o lock nativo

No projeto de exemplo, o backend fica dividido em duas partes. O backend.tf contém o que é igual em qualquer ambiente, incluindo use_lockfile = true, e os valores que variam por conta vão para um arquivo de configuração parcial, passado no init.

app/versions.tf

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

app/backend.tf

terraform {
  backend "s3" {
    key          = "projetos/app-demo/terraform.tfstate"
    encrypt      = true
    use_lockfile = true
  }
}

app/backend.hcl.example

bucket = "minha-empresa-terraform-state-123456789012"
region = "sa-east-1"

Repare que não há dynamodb_table em lugar nenhum. Com encrypt = true, o backend pede criptografia do lado do servidor tanto para o state quanto para o arquivo de lock, como descreve a documentação do argumento.

app/variables.tf

variable "aws_region" {
  description = "Região AWS dos recursos da aplicação."
  type        = string
}

variable "ambiente" {
  description = "Nome do ambiente (ex.: dev, staging, prod)."
  type        = string
  default     = "dev"
}

app/main.tf

resource "aws_ssm_parameter" "versao_app" {
  name        = "/app-demo/${var.ambiente}/versao"
  description = "Parâmetro de exemplo para exercitar o lock do state."
  type        = "String"
  value       = "1.0.0"
}

app/terraform.tfvars.example

aws_region = "sa-east-1"
ambiente   = "dev"

Executando o projeto

Copie os exemplos, inicialize passando a configuração parcial do backend e rode o fluxo normal. O init deve informar que o backend s3 foi configurado com sucesso.

cd app
cp backend.hcl.example backend.hcl
cp terraform.tfvars.example terraform.tfvars
terraform init -backend-config=backend.hcl
terraform plan -out=app.tfplan
terraform apply app.tfplan

Enquanto o apply roda, liste o prefixo em outro terminal. Você deve ver o arquivo .tflock aparecer ao lado do state e sumir quando a operação terminar.

aws s3 ls s3://minha-empresa-terraform-state-123456789012/projetos/app-demo/

Testando a concorrência de verdade

O teste que importa é provocar duas execuções simultâneas. Abra dois terminais no diretório app. No primeiro, rode um apply sem plano salvo e deixe-o parado no prompt de confirmação, que mantém o lock adquirido. No segundo, rode um plan.

# Terminal 1: segura o lock enquanto espera a confirmação
terraform apply

# Terminal 2: deve falhar ao adquirir o lock
terraform plan

O segundo terminal deve falhar com uma mensagem de erro de aquisição de lock, mostrando o ID da trava, a operação, quem a criou e a versão do Terraform. Responda "no" no primeiro terminal e rode o plan de novo no segundo: agora ele deve passar. Se quiser que o segundo processo espere em vez de falhar, use -lock-timeout, por exemplo terraform plan -lock-timeout=5m, o que é útil em pipelines que podem disparar quase ao mesmo tempo.

Passo 3: auditando locks esquecidos com TypeScript

Um lock pode ficar órfão quando um job de CI é cancelado no meio, a máquina perde a rede ou alguém fecha o terminal à força. O Terraform não remove travas sozinho por tempo, o que é correto, porque apagar a trava de uma execução viva é exatamente o desastre que o lock evita. O script abaixo apenas encontra travas antigas e mostra os metadados para que uma pessoa decida.

scripts/package.json

{
  "name": "tf-lock-auditor",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "audit:locks": "node --env-file=.env --import tsx src/audit-locks.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@aws-sdk/client-s3": "^3.1159.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2"
  }
}

scripts/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

scripts/.env.example

AWS_REGION=sa-east-1
TF_STATE_BUCKET=minha-empresa-terraform-state-123456789012
TF_STATE_PREFIX=projetos/
STALE_LOCK_MINUTES=60

scripts/src/audit-locks.ts

import {
  GetObjectCommand,
  ListObjectsV2Command,
  S3Client,
  type _Object,
} from "@aws-sdk/client-s3";

interface TerraformLockInfo {
  ID: string;
  Operation: string;
  Info: string;
  Who: string;
  Version: string;
  Created: string;
  Path: string;
}

interface StaleLock {
  key: string;
  ageMinutes: number;
  lock: TerraformLockInfo | null;
}

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value || value.trim() === "") {
    throw new Error(`Variável de ambiente obrigatória ausente: ${name}`);
  }
  return value.trim();
}

const region = requireEnv("AWS_REGION");
const bucket = requireEnv("TF_STATE_BUCKET");
const prefix = process.env.TF_STATE_PREFIX ?? "";
const staleMinutes = Number(process.env.STALE_LOCK_MINUTES ?? "60");

if (!Number.isFinite(staleMinutes) || staleMinutes <= 0) {
  throw new Error("STALE_LOCK_MINUTES precisa ser um número positivo.");
}

const s3 = new S3Client({ region });

async function listLockObjects(): Promise<_Object[]> {
  const locks: _Object[] = [];
  let continuationToken: string | undefined;

  do {
    const page = await s3.send(
      new ListObjectsV2Command({
        Bucket: bucket,
        Prefix: prefix,
        ContinuationToken: continuationToken,
      }),
    );

    for (const object of page.Contents ?? []) {
      if (object.Key?.endsWith(".tflock")) {
        locks.push(object);
      }
    }

    continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
  } while (continuationToken);

  return locks;
}

async function readLockInfo(key: string): Promise<TerraformLockInfo | null> {
  const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
  const body = await response.Body?.transformToString("utf-8");
  if (!body) {
    return null;
  }
  try {
    return JSON.parse(body) as TerraformLockInfo;
  } catch {
    return null;
  }
}

async function main(): Promise<void> {
  const now = Date.now();
  const lockObjects = await listLockObjects();
  const stale: StaleLock[] = [];

  for (const object of lockObjects) {
    if (!object.Key || !object.LastModified) {
      continue;
    }
    const ageMinutes = Math.floor((now - object.LastModified.getTime()) / 60_000);
    if (ageMinutes >= staleMinutes) {
      stale.push({ key: object.Key, ageMinutes, lock: await readLockInfo(object.Key) });
    }
  }

  console.log(`Locks encontrados: ${lockObjects.length}. Antigos (>= ${staleMinutes} min): ${stale.length}.`);

  for (const item of stale) {
    console.log(
      JSON.stringify({
        key: item.key,
        ageMinutes: item.ageMinutes,
        lockId: item.lock?.ID ?? "desconhecido",
        operation: item.lock?.Operation ?? "desconhecida",
        who: item.lock?.Who ?? "desconhecido",
        terraformVersion: item.lock?.Version ?? "desconhecida",
      }),
    );
  }

  process.exitCode = stale.length > 0 ? 2 : 0;
}

main().catch((error: unknown) => {
  console.error("Falha ao auditar locks:", error);
  process.exitCode = 1;
});

O script usa ListObjectsV2 com paginação, filtra chaves terminadas em .tflock, calcula a idade a partir de LastModified e lê o JSON de cada trava antiga. O código de saída é 2 quando há locks antigos, o que facilita usá-lo em um job agendado que alerta o time. As credenciais vêm da cadeia padrão do AWS SDK, como perfil, variáveis de ambiente ou role do CI, e nunca devem ser gravadas no .env.

cd scripts
npm install
cp .env.example .env
npm run typecheck
npm run audit:locks

A identidade que roda o script precisa de s3:ListBucket no prefixo e s3:GetObject nos arquivos .tflock. Ela não precisa, e não deve ter, s3:DeleteObject: a remoção de uma trava continua sendo feita pelo próprio Terraform.

Quando você confirmar que a execução dona da trava morreu de fato, libere o lock com o comando oficial, usando o ID mostrado no erro ou no relatório do script. Rode-o no diretório do projeto, já inicializado com o mesmo backend.

terraform force-unlock <LOCK_ID>

Migrando um projeto que usa DynamoDB

A documentação do backend prevê justamente a transição: os argumentos de S3 e DynamoDB podem ser configurados ao mesmo tempo, e nesse caso o Terraform adquire o lock nas duas fontes. Isso permite uma migração em duas etapas sem nenhuma janela em que algum executor rode sem trava.

Na primeira etapa, acrescente use_lockfile = true mantendo o dynamodb_table, atualize todos os executores (máquinas do time e imagens de CI) para Terraform 1.11 ou superior e garanta que a policy IAM já inclui o .tflock. Como bucket e key não mudam, não há state para copiar; rode terraform init -reconfigure para que o diretório de trabalho adote a nova configuração do backend. A documentação do init explica que -reconfigure ignora a configuração anterior sem migrar state, enquanto -migrate-state tenta copiar o state para um backend novo.

terraform {
  backend "s3" {
    key            = "projetos/app-demo/terraform.tfstate"
    encrypt        = true
    use_lockfile   = true
    dynamodb_table = "terraform-locks"
  }
}

Na segunda etapa, quando tiver certeza de que nenhum executor antigo ainda depende só do DynamoDB, remova o dynamodb_table, rode terraform init -reconfigure novamente e só então apague a tabela e as permissões de DynamoDB. A ordem importa: se você tirar o DynamoDB antes de todos os executores usarem o arquivo de lock, um executor desatualizado pode rodar travando em um lugar que os outros já não consultam.

Erros comuns

O erro mais frequente depois da migração é AccessDenied ao adquirir ou liberar o lock. Quase sempre a policy permite GetObject e PutObject no state, mas esqueceu o arquivo .tflock ou o DeleteObject sobre ele. Sem DeleteObject, o Terraform consegue travar, mas não consegue destravar, e o próximo comando encontra um lock que parece órfão.

Outro tropeço é esquecer de rodar o init depois de mudar o bloco backend. O Terraform detecta que a configuração do backend mudou e se recusa a seguir até que você escolha entre -reconfigure e -migrate-state. Em pipelines, isso aparece como uma falha logo no primeiro comando, e a correção é ajustar o passo de inicialização.

Buckets com Object Lock merecem atenção. O changelog do Terraform 1.10 registra a correção de um bug que impedia a gravação do lock em buckets com Object Lock habilitado, o que reforça a recomendação de usar 1.11 ou superior. Se a sua organização usa Object Lock no bucket de state, teste o fluxo completo de travar e destravar em uma conta separada antes de migrar.

Por fim, cuidado com workspaces. Com o workspace_key_prefix padrão, o state do workspace staging fica em env:/staging/<key>, e o lock acompanha esse caminho. Uma policy que libera apenas projetos/* vai negar acesso a esses objetos, e o erro só aparece quando alguém roda terraform workspace select.

Segurança

Trate o bucket de state como um cofre. O arquivo pode conter valores sensíveis em texto claro, mesmo quando as variáveis estão marcadas como sensitive, porque essa marcação esconde os valores da saída do terminal, não do state. Mantenha o bloqueio de acesso público, a exigência de TLS e a criptografia, e restrinja leitura do state apenas a quem realmente precisa.

Separe permissões por prefixo e por ambiente. A documentação oficial mostra como limitar uma identidade ao state de produção, incluindo o .tflock correspondente, e esse é o padrão que você deve replicar: o pipeline de desenvolvimento não deveria conseguir gravar o state de produção, nem mesmo criar o arquivo de lock dele.

Evite sse_customer_key no próprio código. A documentação alerta que definir esse valor em um arquivo Terraform faz com que ele acabe persistido em disco, e recomenda a variável de ambiente AWS_SSE_CUSTOMER_KEY quando esse modo for necessário. Na maioria dos casos, SSE-S3 ou SSE-KMS resolvem sem esse risco.

Performance e custos

Do ponto de vista de custo, o ganho principal é operacional: um serviço a menos para provisionar, monitorar e auditar. Cada operação passa a fazer algumas requisições S3 extras para criar, ler e apagar o arquivo de lock, cobradas conforme a tabela de preços de requisições do S3 na sua região. Não há benchmark oficial comparando os dois mecanismos, então não vale prometer números; se o custo for relevante para você, observe as métricas de requisições do bucket antes e depois da migração.

O versionamento tem um efeito colateral que precisa ser gerenciado. Cada apply gera uma nova versão do state, e cada ciclo de lock cria e apaga um objeto, o que em bucket versionado deixa marcadores de exclusão e versões não correntes. A regra de ciclo de vida do bootstrap, que expira versões antigas após 90 dias por padrão, mantém esse histórico sob controle sem abrir mão da recuperação de curto prazo.

Limitações

O lock nativo protege contra execuções simultâneas do Terraform, e só isso. Ele não impede que alguém altere recursos direto no console da AWS, não substitui revisão de plano em pull request e não resolve conflitos entre projetos que gerenciam o mesmo recurso por engano. Para esses problemas, continue usando detecção de drift, revisão de código e uma divisão clara de responsabilidade entre states.

Também vale lembrar que este tutorial não foi executado contra uma conta AWS real durante a preparação. Os arquivos Terraform passaram por terraform fmt -check e terraform validate com o Terraform 1.17.1 e o provider AWS 6.x, e o script passou pela checagem de tipos do TypeScript 7.0.2, mas as saídas de apply, de erro de lock e do relatório do script descritas aqui são o comportamento esperado, não registros de uma execução.

Conclusão

O lock de state com use_lockfile no S3 elimina a tabela DynamoDB que muitos times mantinham só para o Terraform, sem abrir mão da proteção contra execuções concorrentes. A base técnica é sólida: escrita condicional do S3 e um arquivo JSON ao lado do state, com o mesmo tipo de informação que o DynamoDB guardava.

O caminho seguro é o que fizemos aqui: um bucket endurecido criado por um bootstrap separado, uma policy de privilégio mínimo que inclui o .tflock, um teste real de concorrência e uma migração em duas etapas para quem vem do DynamoDB. Com o script de auditoria rodando em um job agendado, travas órfãs deixam de ser surpresa e passam a ser um alerta com contexto suficiente para agir com calma.

Referências

Precisa implementar isso na sua empresa?

Quer aplicar essas práticas de engenharia, dados ou inteligência artificial no seu produto? Solicite um diagnóstico técnico sem compromisso.

Solicitar orçamento