Voltar ao blog
TerraformPulumiOpenTofuIaCInfrastructure

Terraform vs Pulumi: um stack de Kubernetes testado e revisto duas vezes

Um stack de Kubernetes em HCL e TypeScript, testado sem cluster: segredos no estado, revisão do plano, importação, licenças e uma regra de decisão.

P
Davi Nunes
March 14, 2026Atualizado November 12, 202615 min de leitura

A maioria das comparações entre Terraform e Pulumi fica-se por "o HCL é declarativo, o Pulumi usa linguagens reais, escolhe pelas competências da equipa". É verdade, e não ajuda a decidir. As diferenças que importam aparecem mais tarde: no que o ficheiro de estado guarda, na forma como testas, no que um revisor vê antes de uma alteração chegar a produção e na dificuldade de sair.

Por isso, este artigo constrói um stack pequeno e realista nas duas ferramentas e corre-o. Cada listagem HCL abaixo passou o validate no Terraform e no OpenTofu (o único bloco exclusivo do OpenTofu está assinalado), e os testes passaram a verde em ambos; cada listagem TypeScript passou o tsc, e os respetivos testes unitários passaram a verde.

O stack: uma equipa de plataforma dá a cada equipa de produto o seu próprio namespace de Kubernetes, com uma ResourceQuota e um LimitRange. Assim que uma quota cobre CPU ou memória, o Kubernetes pode rejeitar qualquer pod novo que não declare requests ou limits para esses recursos, por isso o LimitRange fornece valores por omissão. Um mapa de equipas obriga a um ciclo, e três recursos por equipa pedem reutilização.

O stack em HCL

O módulo raiz chama um módulo filho uma vez por entrada do mapa com for_each. A regra de validação rejeita nomes que o Kubernetes recusaria como nomes de namespace antes de qualquer coisa chegar à API.

hcl
variable "teams" {
  description = "Team name => namespace quota"
  type = map(object({
    cpu    = string
    memory = string
    pods   = optional(number, 50)
  }))

  validation {
    condition     = alltrue([for name in keys(var.teams) : can(regex("^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$", name))])
    error_message = "Team names must be valid namespace names (DNS-1123 labels)."
  }
}

module "team" {
  source   = "./modules/team-namespace"
  for_each = var.teams

  name   = each.key
  cpu    = each.value.cpu
  memory = each.value.memory
  pods   = each.value.pods
}

O módulo contém os três recursos e dois outputs para os testes:

hcl
variable "name" { type = string }
variable "cpu" { type = string }
variable "memory" { type = string }
variable "pods" { type = number }

resource "kubernetes_namespace_v1" "this" {
  metadata {
    name   = var.name
    labels = { team = var.name }
  }
}

resource "kubernetes_resource_quota_v1" "this" {
  metadata {
    name      = "team-quota"
    namespace = kubernetes_namespace_v1.this.metadata[0].name
  }
  spec {
    hard = {
      "requests.cpu"    = var.cpu
      "requests.memory" = var.memory
      pods              = tostring(var.pods)
    }
  }
}

resource "kubernetes_limit_range_v1" "this" {
  metadata {
    name      = "container-defaults"
    namespace = kubernetes_namespace_v1.this.metadata[0].name
  }
  spec {
    limit {
      type            = "Container"
      default_request = { cpu = "100m", memory = "128Mi" }
      default         = { cpu = "500m", memory = "512Mi" }
    }
  }
}

output "namespace" { value = kubernetes_namespace_v1.this.metadata[0].name }
output "quota" { value = kubernetes_resource_quota_v1.this.spec[0].hard }

A versão fixada do provider fica em versions.tf e a lista de equipas num ficheiro .tfvars. Cada instância recebe um endereço como module.team["payments"]: a chave do mapa é a sua identidade, o que volta a importar na secção de revisão.

O mesmo stack em Pulumi com TypeScript

O programa lê o mesmo mapa da configuração do stack e percorre-o num ciclo:

typescript
import * as pulumi from "@pulumi/pulumi";
import { TeamNamespace, TeamArgs } from "./team";

const config = new pulumi.Config();
const teams = config.requireObject<Record<string, TeamArgs>>("teams");

export const namespaces = Object.entries(teams).map(
  ([name, args]) => new TeamNamespace(name, args).namespace,
);

As três chamadas new podiam ficar dentro desse ciclo. Assim que um segundo stack precisar delas, move-as para um ComponentResource, a unidade de reutilização do Pulumi:

typescript
import * as pulumi from "@pulumi/pulumi";
import * as k8s from "@pulumi/kubernetes";

export interface TeamArgs {
  cpu: string;
  memory: string;
  pods?: number;
}

export class TeamNamespace extends pulumi.ComponentResource {
  public readonly namespace: pulumi.Output<string>;
  public readonly quota: k8s.core.v1.ResourceQuota;

  constructor(name: string, args: TeamArgs, opts?: pulumi.ComponentResourceOptions) {
    if (!/^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/.test(name)) {
      throw new Error(`team name "${name}" is not a DNS-1123 label`);
    }
    super("platform:k8s:TeamNamespace", name, {}, opts);
    const child = { parent: this };

    const ns = new k8s.core.v1.Namespace(name, {
      metadata: { name, labels: { team: name } },
    }, child);

    this.quota = new k8s.core.v1.ResourceQuota(`${name}-quota`, {
      metadata: { name: "team-quota", namespace: ns.metadata.name },
      spec: {
        hard: {
          "requests.cpu": args.cpu,
          "requests.memory": args.memory,
          pods: String(args.pods ?? 50),
        },
      },
    }, child);

    new k8s.core.v1.LimitRange(`${name}-defaults`, {
      metadata: { name: "container-defaults", namespace: ns.metadata.name },
      spec: {
        limits: [{
          type: "Container",
          defaultRequest: { cpu: "100m", memory: "128Mi" },
          default: { cpu: "500m", memory: "512Mi" },
        }],
      },
    }, child);

    this.namespace = ns.metadata.name;
    this.registerOutputs({ namespace: this.namespace });
  }
}

Três detalhes diferem da versão HCL:

  • A validação é código normal. A verificação do nome lança uma exceção no construtor, por isso um nome inválido faz falhar o pulumi preview tal como a validação HCL faz falhar o terraform plan.
  • A identidade é o nome lógico. payments-quota e o componente pai formam o URN do recurso, o equivalente no Pulumi ao endereço do módulo. Muda o nome lógico e o Pulumi planeia uma eliminação e uma criação.
  • Define tu o metadata.name. Sem ele, o Pulumi atribui um nome automático ao objeto: um Namespace declarado como payments sem nome ficou payments-7dfa2b14 quando o corremos. Útil quando dois stacks fazem deploy do mesmo programa; uma surpresa para um namespace que os programadores escrevem todos os dias.

Estado, bloqueios e os segredos que contém

As duas ferramentas guardam um estado que liga o código aos objetos reais, incluindo os atributos dos recursos.

  • O Terraform e o OpenTofu escolhem um backend num bloco backend. Duas opções neutras em relação ao fabricante: kubernetes guarda o estado num Secret e bloqueia com um Lease; pg guarda-o numa tabela PostgreSQL e bloqueia com advisory locks. O armazenamento de objetos e o serviço alojado do fabricante são as alternativas habituais.
  • O Pulumi escolhe um com pulumi login: por omissão, o serviço gerido do fabricante, ou então um backend autogerido (uma diretoria local, armazenamento de objetos ou PostgreSQL). Os backends autogeridos bloqueiam por omissão com um ficheiro de bloqueio e guardam o histórico de checkpoints em .pulumi/history/.

É nos segredos que diferem. Fizemos a mesma experiência nas duas: passar uma palavra-passe marcada como sensível, aplicar e procurá-la no estado.

text
# Terraform: a variable with sensitive = true, passed to a resource
$ grep -c 's3cr3t-demo' terraform.tfstate
2

# Pulumi: the same value, set with pulumi config set --secret
$ grep -c 's3cr3t-demo' .pulumi/stacks/secrets-demo/dev.json
0

No Terraform, sensitive = true apenas esconde o valor no output da CLI; o estado guarda-o em texto simples, aqui duas vezes. O Pulumi encripta todos os valores marcados como segredo, e todos os valores derivados deles, com uma chave por stack: por omissão, a chave do serviço gerido; nos backends autogeridos, por omissão, uma frase-passe; ou então o serviço de gestão de chaves da tua cloud. O provider de Kubernetes marca automaticamente como segredo os campos data e stringData dos Secrets. Os valores não marcados como segredo ficam em JSON simples ao lado do texto cifrado.

As ferramentas HCL encurtaram a distância:

  • O Terraform 1.10 acrescentou os valores efémeros e a 1.11 os argumentos só de escrita, que nunca são escritos nos ficheiros de estado nem de plano. O recurso Secret do provider de Kubernetes tem um, data_wo; incrementas data_wo_revision para enviar um valor novo. O OpenTofu 1.11 acrescentou o mesmo.
  • O OpenTofu 1.7 acrescentou a encriptação do lado do cliente dos ficheiros de estado e de plano. O Terraform 1.16 rejeita o bloco abaixo como tipo de bloco não suportado. Com o OpenTofu, o ficheiro de estado em disco passou a ser um blob encrypted_data:
hcl
terraform {
  encryption {
    key_provider "pbkdf2" "main" {
      passphrase = var.state_passphrase
    }
    method "aes_gcm" "main" {
      keys = key_provider.pbkdf2.main
    }
    state {
      method   = method.aes_gcm.main
      enforced = true
    }
    plan {
      method   = method.aes_gcm.main
      enforced = true
    }
  }
}

Se perderes a frase-passe, o estado é irrecuperável; guarda uma cópia antes.

A regra que daqui resulta: quem consegue ler um backend de Terraform não encriptado consegue ler todos os segredos que lá estão, por isso acesso ao estado é acesso às credenciais de produção. Com uma frase-passe do Pulumi ou o teu próprio serviço de gestão de chaves, ou com o estado do OpenTofu encriptado, podes conceder a chave separadamente do acesso ao backend.

Testes sem cluster

O Terraform 1.7 acrescentou mock_provider ao terraform test: um provider falso preenche os atributos calculados com valores gerados, por isso os testes não precisam de cluster nem de credenciais. Este ficheiro fica em tests/:

hcl
mock_provider "kubernetes" {}

variables {
  teams = {
    payments = { cpu = "8", memory = "16Gi" }
    search   = { cpu = "4", memory = "8Gi", pods = 20 }
  }
}

run "one_namespace_per_team" {
  command = plan

  assert {
    condition     = toset(keys(module.team)) == toset(["payments", "search"])
    error_message = "Expected one module instance per team."
  }

  assert {
    condition     = module.team["search"].quota["pods"] == "20"
    error_message = "The pods override did not reach the quota."
  }

  assert {
    condition     = module.team["payments"].quota["pods"] == "50"
    error_message = "Teams without an override should get 50 pods."
  }
}

run "rejects_invalid_team_name" {
  command = plan

  variables {
    teams = { "Payments_EU" = { cpu = "1", memory = "1Gi" } }
  }

  expect_failures = [var.teams]
}
text
$ terraform test
tests/teams.tftest.hcl... in progress
  run "one_namespace_per_team"... pass
  run "rejects_invalid_team_name"... pass
tests/teams.tftest.hcl... tearing down
tests/teams.tftest.hcl... pass

Success! 2 passed, 0 failed.

expect_failures transforma a regra de validação num teste: o run só passa se a regra rejeitar a entrada. O mesmo ficheiro passou sem alterações com tofu test no OpenTofu 1.13. Para provar que o teste consegue falhar, mudámos o valor por omissão para 40 pods, e a terceira asserção falhou com "40" contra "50".

Os testes unitários do Pulumi correm no test runner habitual da tua linguagem, com pulumi.runtime.setMocks a substituir o motor. Corremos isto com o runner integrado do Node (npx tsx --test team.test.ts) numa máquina sem a CLI do Pulumi, sem backend e sem cluster:

typescript
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import * as pulumi from "@pulumi/pulumi";
import { TeamNamespace } from "./team";

// Resolve an Output in a test. Mocks make every value known.
const valueOf = <T>(o: pulumi.Output<T>) =>
  new Promise<T>((resolve) => o.apply(resolve));

describe("TeamNamespace", () => {
  before(async () => {
    await pulumi.runtime.setMocks({
      newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
      call: (args) => args.inputs,
    });
  });

  it("defaults the pod quota to 50", async () => {
    const team = new TeamNamespace("payments", { cpu: "8", memory: "16Gi" });
    const hard = await valueOf(team.quota.spec.hard);
    assert.equal(hard["pods"], "50");
    assert.equal(hard["requests.cpu"], "8");
  });

  it("passes a pod override through", async () => {
    const team = new TeamNamespace("search", { cpu: "4", memory: "8Gi", pods: 20 });
    assert.equal((await valueOf(team.quota.spec.hard))["pods"], "20");
  });

  it("rejects names that are not valid namespaces", () => {
    assert.throws(
      () => new TeamNamespace("Payments_EU", { cpu: "1", memory: "1Gi" }),
      /DNS-1123/,
    );
  });
});

Os três passaram; a alteração do valor por omissão para 40 fez falhar o primeiro. Se o ficheiro em teste criar recursos ao ser importado, como faz um index.ts típico, carrega-o com um import() dinâmico depois de setMocks.

Em que é boa cada abordagem:

  • terraform test verifica o plano ou o estado que o motor real produz, incluindo a ligação entre módulos e a validação. Aplicam-se as regras de tipos do HCL: comparar um output do tipo lista com ["Container"] falhou até o envolvermos em tolist(), porque um literal entre parênteses retos é um tuplo.
  • Os testes do Pulumi são código normal, com fixtures e helpers. Mas o mock devolve as entradas tal como estão, por isso testas a tua lógica, não o comportamento do provider.
  • Nenhum substitui um apply real contra um cluster descartável antes de uma release.

Queres comparar as duas no teu próprio código? Pede uma avaliação gratuita de Infraestrutura como Código: lemos os teus módulos ou programas, verificamos o que está no teu estado, corremos os teus testes e entregamos-te uma lista de riscos ordenada por impacto.

Ciclos, condicionais e o problema do "dinâmico"

Os ciclos simples são fáceis nas duas; os blocos aninhados opcionais e os ciclos aninhados, não. Duas extensões mostram-no: um limite de tamanho para PersistentVolumeClaim só nas equipas que definem max_pvc_size, e um RoleBinding edit por cada grupo da equipa.

Em HCL, um bloco opcional é um bloco dynamic sobre uma lista de zero ou um elementos, e um ciclo aninhado precisa de flatten mais uma expressão de mapa para ter chaves de for_each estáveis:

hcl
# modules/team-namespace/main.tf, inside the LimitRange spec
dynamic "limit" {
  for_each = var.max_pvc_size == null ? [] : [var.max_pvc_size]
  content {
    type = "PersistentVolumeClaim"
    max  = { storage = limit.value }
  }
}

# main.tf: one RoleBinding per team and group
locals {
  team_groups = flatten([
    for team, cfg in var.teams : [
      for group in cfg.groups : { team = team, group = group }
    ]
  ])
}

resource "kubernetes_role_binding_v1" "edit" {
  for_each = { for tg in local.team_groups : "${tg.team}/${tg.group}" => tg }

  metadata {
    name      = "edit-${each.value.group}"
    namespace = module.team[each.value.team].namespace
  }
  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = "edit"
  }
  subject {
    api_group = "rbac.authorization.k8s.io"
    kind      = "Group"
    name      = each.value.group
  }
}

O TypeScript é o código que escreverias de qualquer forma:

typescript
// team.ts, the LimitRange spec
limits: [
  {
    type: "Container",
    defaultRequest: { cpu: "100m", memory: "128Mi" },
    default: { cpu: "500m", memory: "512Mi" },
  },
  ...(args.maxPvcSize
    ? [{ type: "PersistentVolumeClaim", max: { storage: args.maxPvcSize } }]
    : []),
],

// index.ts: one RoleBinding per team and group
for (const [team, cfg] of Object.entries(teams)) {
  const t = new TeamNamespace(team, cfg);
  for (const group of cfg.groups ?? []) {
    new k8s.rbac.v1.RoleBinding(`${team}-${group}`, {
      metadata: { name: `edit-${group}`, namespace: t.namespace },
      roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: "edit" },
      subjects: [{ apiGroup: "rbac.authorization.k8s.io", kind: "Group", name: group }],
    });
  }
}

As duas versões passaram nos testes, com o limite de PVC apenas em payments, e os bindings do HCL ficaram com as chaves payments/payments-devs, payments/payments-oncall e search/search-devs. O HCL funciona, mas cada um destes padrões é mais uma coisa que o revisor tem de aprender.

O perigo vai no sentido contrário. Um programa Pulumi corre por inteiro em cada preview:

  • Chamadas de rede durante o plano. Se fores buscar a lista de equipas a uma API interna, cada preview depende de essa API responder sempre da mesma forma. Guarda os dados no repositório ou usa a configuração do stack.
  • Não determinismo. Marcas temporais ou valores aleatórios em nomes ou entradas produzem um diff a cada execução.
  • Efeitos secundários. O código que escreve ficheiros ou chama webhooks também corre durante o pulumi preview, e não só no pulumi up.

O HCL torna estes erros mais difíceis, não impossíveis: também tem timestamp() e uuid(). A nossa regra para código Pulumi: as entradas vêm da configuração, de ficheiros versionados ou dos outputs de outros recursos, e a revisão rejeita tudo o resto.

Reutilização e revisão: o que vê quem revê

Na reutilização, um módulo de Terraform é fixado por tag de Git ou versão do registry e serve tanto o Terraform como o OpenTofu; desenhar essas fronteiras entre módulos é boa parte do nosso trabalho de consultoria de Terraform. Um componente Pulumi em TypeScript é distribuído como pacote npm para outros programas TypeScript.

Na revisão, fizemos a mesma alteração nas duas: subir o CPU de payments de 8 para 12 e mudar o nome da equipa search para discovery. O plano do Terraform, obtido com terraform test -verbose e o mock provider (excerto):

text
  # module.team["payments"].kubernetes_resource_quota_v1.this will be updated in-place
  ~ resource "kubernetes_resource_quota_v1" "this" {
        id = "74w79olp"
      ~ spec {
          ~ hard = {
              ~ "requests.cpu"    = "8" -> "12"
                # (2 unchanged elements hidden)
            }
        }
        # (1 unchanged block hidden)
    }

  # module.team["search"].kubernetes_namespace_v1.this will be destroyed
  # (because module.team["search"] is not in configuration)

Plan: 3 to add, 1 to change, 3 to destroy.

O preview do Pulumi, corrido contra um provider que escreve os manifestos numa diretoria em vez de num cluster (excerto):

text
    ~ kubernetes:core/v1:ResourceQuota: (update)
        [id=payments/team-quota]
      ~ spec: {
          ~ hard: {
              ~ requests.cpu: "8" => "12"
            }
        }
- kubernetes:core/v1:Namespace: (delete)
    [id=search]

O resumo indica 4 a criar, 1 a atualizar e 4 a eliminar: o Pulumi conta o próprio componente como um recurso.

Ambos mostram a armadilha: mudar o nome de uma chave do mapa elimina um namespace e, com ele, tudo o que lá corre. Há duas diferenças que importam a quem revê:

  • Um plano do Terraform guardado com -out é aplicado exatamente como foi aprovado, e terraform show -json transforma-o numa evidência de alteração que se pode arquivar. O --save-plan do Pulumi continua marcado como funcionalidade em preview, por isso o pulumi up normalmente volta a calcular o diff no momento de aplicar.
  • Revê o plano, e não só o diff do código. No Pulumi isto importa mais: uma alteração numa função auxiliar pode mexer em recursos longe das linhas que um pull request toca.

Não deixes a armadilha entregue ao olho humano. Este passo de CI falha quando uma alteração eliminaria um namespace:

bash
# Terraform or OpenTofu
terraform plan -input=false -out=tfplan
terraform show -json tfplan | jq -r '.resource_changes[]
  | select(.type == "kubernetes_namespace_v1")
  | select(.change.actions | index("delete")) | .address' > deleted.txt

# Pulumi
pulumi preview --json | jq -r '.steps[]
  | select(.op | test("delete|replace"))
  | select(.oldState.type == "kubernetes:core/v1:Namespace") | .urn' > deleted.txt

# Either tool
if [ -s deleted.txt ]; then echo "This change deletes namespaces:"; cat deleted.txt; exit 1; fi

No código, lifecycle { prevent_destroy = true } faz o Terraform recusar esses planos; remover uma equipa falha então com "Instance cannot be destroyed". A opção protect: true do Pulumi faz o mesmo. Para refatorizações que mantêm o objeto, como mover um recurso para um módulo ou componente, usa blocos moved em HCL ou a opção aliases no Pulumi; mudar o nome do próprio namespace recria-o sempre. Integrar estes controlos nos pipelines faz parte do nosso trabalho de consultoria de CI/CD.

Drift, importação e migração entre ferramentas

O drift é uma alteração feita fora da ferramenta. Verifica-o de forma agendada:

bash
# Terraform or OpenTofu: show drift without proposing changes
terraform plan -refresh-only
# Exit code 0 = no changes, 1 = error, 2 = changes
terraform plan -detailed-exitcode

# Pulumi: show what a refresh would change, without writing state
pulumi refresh --preview-only
# Fail if code and reality differ
pulumi preview --refresh --expect-no-changes

Aceitar o drift (apply -refresh-only ou pulumi refresh escrevem a realidade no estado) e revertê-lo (aplicar o código) são decisões diferentes; toma uma de propósito.

Para adotar namespaces existentes, o Terraform 1.5 acrescentou os blocos import e a 1.7 passou a permitir for_each neles. Os formatos de ID são os que o provider documenta: o nome para um namespace e NAMESPACE/NAME para uma quota.

hcl
import {
  for_each = var.teams
  to       = module.team[each.key].kubernetes_namespace_v1.this
  id       = each.key
}

import {
  for_each = var.teams
  to       = module.team[each.key].kubernetes_resource_quota_v1.this
  id       = "${each.key}/team-quota"
}

terraform plan -generate-config-out=generated.tf escreve HCL para os recursos importados sem configuração; as duas CLIs continuam a marcá-lo como experimental. No Pulumi, pulumi import TYPE NAME ID adota um recurso e imprime o respetivo código, --file aceita uma lista em JSON e pulumi preview --import-file import.json cria um rascunho dessa lista a partir das criações que o teu programa planeia. O que é importado fica protegido por omissão.

Migrar entre ferramentas:

  • Do Terraform para o OpenTofu: o mesmo HCL; o nosso módulo e os nossos testes correram sem alterações. As funcionalidades acrescentadas desde o fork diferem nos dois sentidos, por isso verifica quais o teu código usa.
  • Do Terraform para o Pulumi: pulumi convert --from terraform --language typescript --out DIR traduz HCL. No stack acima (plugin do conversor 1.4.0) gerou um componente e um ciclo, mas o tsc com o próprio tsconfig do conversor reportou 11 erros, entre eles: tostring() passou a ser uma chamada a notImplemented(...) que lança uma exceção em tempo de execução, as referências ao namespace passaram a ser um this.apply(...) inválido e o bloco limit do LimitRange não foi renomeado para limits. Trata o resultado como um primeiro rascunho. pulumi import --from terraform ./terraform.tfstate adota os recursos registados num ficheiro de estado do Terraform.
  • Lado a lado: um programa Pulumi consegue ler os outputs raiz de um estado do Terraform, por isso podes migrar camada a camada. Isto leu o nosso estado de teste e exportou ["payments", "search"]:
typescript
import * as terraform from "@pulumi/terraform";

const legacy = terraform.state.getLocalReferenceOutput({
  path: "../../tf-out/terraform.tfstate",
});

export const legacyNamespaces = legacy.outputs.apply((o) => o["namespaces"] as string[]);

Para estado remoto, o pacote tem getRemoteReference. Um caminho de saída testado é o que impede a escolha de ferramenta de se tornar lock-in; o nosso guia para evitar o vendor lock-in aplica o mesmo teste aos serviços cloud.

Licenças: Terraform, OpenTofu e Pulumi

  • Terraform: a 10 de agosto de 2023, o fabricante anunciou que as versões futuras passariam da Mozilla Public License 2.0 para a Business Source License 1.1. O ficheiro de licença indica o Terraform 1.6.0 e posteriores. O uso em produção é permitido, exceto oferecer o Terraform a terceiros de forma alojada ou embebida em concorrência com as versões pagas do fabricante. Cada versão passa a MPL 2.0 quatro anos depois de ser publicada.
  • OpenTofu: o fork comunitário do código do Terraform com licença MPL, e continua MPL 2.0. Desde 23 de abril de 2025 é um projeto sandbox da fundação que também aloja o Kubernetes.
  • Pulumi: a CLI, o motor e os SDKs são Apache 2.0. O serviço gerido do fabricante é opcional, com um escalão gratuito e escalões pagos; os backends autogeridos não têm restrições.

Se o Terraform gere a tua própria infraestrutura, a BSL não muda nada no dia a dia. Importa se vendes um produto ou serviço alojado construído sobre ele, ou se a tua política jurídica restringe as licenças sobre as quais podes construir. Nesse caso, pede aos teus juristas que leiam a cláusula de utilização, ou muda para o OpenTofu, que não obriga a reescrever nada.

Uma regra de decisão

Responde por ordem e para na primeira que decidir:

  1. 1Já usas Terraform com módulos que funcionam e revisão de planos? Fica no HCL. Se o problema for a licença, muda para o OpenTofu, não para uma reescrita.
  2. 2Precisas de ficheiros de plano como evidência de alterações, porque um auditor recolhe amostras das alterações? Escolhe Terraform ou OpenTofu: um plano guardado e aplicado tal como foi aprovado é mais fácil de defender do que um diff recalculado no momento de aplicar.
  3. 3Quem o vai manter daqui a dois anos? Uma equipa de plataforma ou de operações cujos membros não escrevem todos TypeScript, Python ou Go diariamente: HCL. Engenheiros de produto que são donos tanto dos serviços como da infraestrutura e reveem código nessa linguagem diariamente: Pulumi. O nosso guia para criar uma equipa de platform engineering trata de quem deve ser dono desta camada.
  4. 4Quanta lógica dinâmica? Escreve primeiro o módulo mais difícil. Se precisar de mais do que um par de blocos dynamic ou de padrões com flatten, o Pulumi vai ler-se melhor. Um for_each sobre um mapa não é motivo para mudar.
  5. 5Estás a construir uma plataforma de autosserviço que aprovisiona a partir do teu próprio serviço? A Automation API do Pulumi corre operações do Pulumi a partir do teu código em vez de chamar uma CLI, uma necessidade comum em platform engineering.
  6. 6Os segredos do estado têm de ser ilegíveis para quem lê o backend? Os segredos do Pulumi ou a encriptação de estado do OpenTofu resolvem isso hoje; com o Terraform, usa argumentos só de escrita onde os providers os oferecerem e restringe o acesso ao backend.

Se nada disto decidir, a nossa opção por omissão é HCL com OpenTofu ou Terraform: quem revê pode aprovar uma alteração lendo o plano, não um programa. Escolhas o que escolheres, escreve os testes e o controlo de eliminações no primeiro mês; são eles, e não a linguagem, que decidem como corre o segundo ano.

Pede uma avaliação gratuita de Infraestrutura como Código

Se estás a escolher entre Terraform, OpenTofu e Pulumi, ou a viver com uma escolha feita há anos, a nossa equipa de consultoria de Terraform revê a tua configuração sem custos. A avaliação cobre:

  • Estado e segredos: onde vive o teu estado, quem o consegue ler, que segredos lá estão em texto simples e como se comportam os bloqueios.
  • Testes e revisão: se os teus módulos ou componentes têm testes que correm sem cluster, e se a CI bloqueia alterações destrutivas antes de chegarem a produção.
  • Adequação da ferramenta e migração: como o teu código se posiciona face à regra de decisão acima e o que implicaria passar para o OpenTofu ou para o Pulumi, se se justificar.

Recebes por escrito uma lista de conclusões e próximos passos, ordenada por risco, sem compromisso.

Pede a tua avaliação gratuita de Infraestrutura como Código, ou vê como fazemos platform engineering sobre essa base.