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.

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/awsna 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
- HashiCorp, "Backend Type: s3", https://developer.hashicorp.com/terraform/language/backend/s3 (consultado em 29/10/2026)
- HashiCorp, documentação de state locking, https://developer.hashicorp.com/terraform/language/state/locking (consultado em 29/10/2026)
- HashiCorp, "Command: init", https://developer.hashicorp.com/terraform/cli/commands/init (consultado em 29/10/2026)
- HashiCorp, "Command: force-unlock", https://developer.hashicorp.com/terraform/cli/commands/force-unlock (consultado em 29/10/2026)
- HashiCorp, Terraform CHANGELOG v1.10, https://github.com/hashicorp/terraform/blob/v1.10/CHANGELOG.md (consultado em 29/10/2026)
- HashiCorp, Terraform CHANGELOG v1.11, https://github.com/hashicorp/terraform/blob/v1.11/CHANGELOG.md (consultado em 29/10/2026)
- HashiCorp, Terraform CHANGELOG v1.17, https://github.com/hashicorp/terraform/blob/v1.17/CHANGELOG.md (consultado em 29/10/2026)
- HashiCorp, código-fonte do backend s3 (v1.17.1), https://github.com/hashicorp/terraform/blob/v1.17.1/internal/backend/remote-state/s3/client.go (consultado em 29/10/2026)
- Terraform Registry, recurso aws_s3_bucket_versioning, https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning (consultado em 29/10/2026)
- AWS, "How to prevent object overwrites with conditional writes", https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html (consultado em 29/10/2026)
- AWS, "Managing the lifecycle of objects", https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html (consultado em 29/10/2026)