Pular para o conteúdo
Como variáveis e segredos chegam aos containers?

Como variáveis e segredos chegam aos containers?

Toda aplicação precisa de configuração, e parte dela é secreta. No ARGLabs, a regra para colocar as duas num container cabe numa linha: uma variável de repositório do Bitbucket chamada ENV_<NAME> chega ao container como a variável de ambiente simples <NAME>; uma variável chamada SECRET_<NAME> é guardada no AWS Secrets Manager e injetada pelo agente do ECS como <NAME>. Nada além disso chega ao container.

Adicionar ou remover uma é só uma mudança nas configurações do repositório e uma execução da pipeline. Sem edição de Terraform, sem edição de pipeline, sem mudança de código. Este post mostra o mecanismo de ponta a ponta na aplicação arglabs-workload, com saídas reais do ambiente dev, e depois explica por que isso é uma boa prática.

O que o repositório tem hoje

Estas são as sete variáveis de repositório do arglabs-workload no Bitbucket, lidas pela API do Pipelines. As protegidas (secured) não têm valor legível, por design.

As sete variáveis de repositório do arglabs-workload no Bitbucket: ENV_DB_USER, ENV_TESTEVAR e ENV_NOVA1 com seus valores visíveis; SECRET_DB_PASS, SECRET_TESTSECRET, SECRET_NOVA2 e ENV_CAMUFLADO protegidas, com os valores mascarados.

Um detalhe importa: ENV_CAMUFLADO está marcada como secured no Bitbucket, mas ainda começa com ENV_, então vira uma variável simples. Dentro de uma pipeline, uma variável secured é indistinguível de uma simples (secured só mascara o log), então é o prefixo que diz “esta é secreta”, não a marca do Bitbucket.

O que o container enxerga

O app de exemplo tem dois endpoints exatamente para esta verificação. GET /env despeja o ambiente do container, variáveis simples e segredos juntos; GET /secret responde apenas os nomes que foram injetados como segredos. Os dois mostram os valores às claras de propósito, já que esta é uma aplicação de laboratório e o objetivo é ver o que chegou. Aqui está o /env no dev, reduzido aos nomes que importam (as entradas de OpenTelemetry, metadados do ECS e PATH foram cortadas):

$ curl -s https://workload.dev.aio.arglabs.org/env
{
  "env": {
    "APP_ENV_KEYS": "CAMUFLADO,DB_USER,NOVA1,TESTEVAR",
    "APP_SECRET_KEYS": "DB_PASS,NOVA2,TESTSECRET",
    "CAMUFLADO": "testepraveroqueacontece",
    "DB_PASS": "argarglabs",
    "DB_USER": "Arglabs",
    "HOSTNAMES": "workload-use1.dev.aio.arglabs.org,workload.dev.aio.arglabs.org",
    "NOVA1": "wedwedwedw",
    "NOVA2": "nova2secret",
    "REGION": "use1",
    "REGION_FULL": "us-east-1",
    "TESTEVAR": "testado",
    "TESTSECRET": "testado"
  },
  "timestamp": "2026-10-13T19:10:35.389175+00:00"
}

Todo nome ENV_ e todo nome SECRET_ do repositório está ali, sem o prefixo. Dois nomes extras são adicionados pelo Terraform: APP_ENV_KEYS lista o que veio como variável simples e APP_SECRET_KEYS o que veio como segredo, porque dentro do container um segredo é uma variável de ambiente como qualquer outra e o processo não tem outro jeito de diferenciá-los. É isso que o /secret lê:

$ curl -s -w 'HTTP %{http_code}\n' https://workload.dev.aio.arglabs.org/secret
{
  "secrets": {
    "DB_PASS": "argarglabs",
    "NOVA2": "nova2secret",
    "TESTSECRET": "testado"
  },
  "timestamp": "2026-10-13T19:10:35.776568+00:00"
}
HTTP 200

DB_USER não está na lista: ela veio de ENV_DB_USER, então é uma variável simples, não um segredo. O endpoint responde 200 somente quando todo segredo listado está presente e não vazio.

A task definition

É aqui que os dois tipos se separam. As partes relevantes do container do app na task definition aio-workload-use1-dev:71 (com o mesmo corte de antes):

"environment": [
  { "name": "APP_ENV_KEYS",    "value": "CAMUFLADO,DB_USER,NOVA1,TESTEVAR" },
  { "name": "APP_SECRET_KEYS", "value": "DB_PASS,NOVA2,TESTSECRET" },
  { "name": "CAMUFLADO",       "value": "testepraveroqueacontece" },
  { "name": "DB_USER",         "value": "Arglabs" },
  { "name": "NOVA1",           "value": "wedwedwedw" },
  { "name": "TESTEVAR",        "value": "testado" }
],
"secrets": [
  { "name": "DB_PASS",    "valueFrom": "arn:aws:secretsmanager:us-east-1:293174400224:secret:aio/workload/dev/app:DB_PASS::" },
  { "name": "NOVA2",      "valueFrom": "arn:aws:secretsmanager:us-east-1:293174400224:secret:aio/workload/dev/app:NOVA2::" },
  { "name": "TESTSECRET", "valueFrom": "arn:aws:secretsmanager:us-east-1:293174400224:secret:aio/workload/dev/app:TESTSECRET::" }
]

As variáveis simples carregam o valor. Os segredos carregam um ARN e uma chave JSON, nada mais: um secret do Secrets Manager por região, aio/workload/dev/app, guarda todos eles como um objeto JSON, e cada entrada aponta para uma chave dele. Nenhum valor secreto está na task definition, então ela pode ser descrita, comparada e registrada em log à vontade.

Como chega lá

Quatro etapas, cada uma guiada por dados, nenhuma citando uma variável pelo nome.

1. A pipeline transforma os prefixos em dois mapas JSON

Todo step de Terraform roda scripts/vars-appvars.sh (compartilhado por todos os apps do ARGLabs por meio do arglabs-cloudteam-sharedpipelines). Ele lê os nomes no ambiente e guarda os que carregam um prefixo:

__appvars_names() {
  awk -v p="$1" 'BEGIN {
    for (k in ENVIRON)
      if (index(k, p) == 1 && length(k) > length(p)) print k
  }' | sort
}

export TF_VAR_app_env="$(__appvars_map ENV_)"

if [ -f terraform/tf-secrets.tf ]; then
  export TF_VAR_app_secrets="$(__appvars_map SECRET_)"
else
  export TF_VAR_app_secrets="{}"
  echo "terraform/tf-secrets.tf is parked: SECRET_* variables are ignored."
fi

O prefixo é a lista de permissão. Tudo o que o Bitbucket injeta por conta própria (BITBUCKET_*, AWS_*, as credenciais OIDC) fica de fora por construção, e AWS_SECRET_ACCESS_KEY também não começa com SECRET_. O log do step do último deploy mostra o resultado; o Bitbucket mascara todo valor secured onde quer que ele apareça, e é por isso que CAMUFLADO aparece impressa como o nome da sua variável, e que TESTEVAR, cujo valor por acaso é igual ao de TESTSECRET (secured), aparece como $SECRET_TESTSECRET:

app_env keys:     CAMUFLADO DB_USER NOVA1 TESTEVAR
app_secret keys:  DB_PASS NOVA2 TESTSECRET
TF_VAR_app_env={"CAMUFLADO":"$ENV_CAMUFLADO","DB_USER":"Arglabs","NOVA1":"wedwedwedw","TESTEVAR":"$SECRET_TESTSECRET"}
TF_VAR_app_secrets=<redacted>

2. O Terraform os recebe como dois mapas

Em terraform/variables.tf, os dois mapas caem em duas variáveis, a segunda sensitive. Só os nomes são extraídos do mapa sensitive, e o nome do secret é fixado por convenção para que o arquivo que o cria e o arquivo que o lê nunca precisem referenciar um ao outro:

variable "app_env"     { type = map(string), default = {} }
variable "app_secrets" { type = map(string), default = {}, sensitive = true }

locals {
  app_env          = merge(var.app_env, { APP_ENV_KEYS = join(",", sort(keys(var.app_env))) })
  app_secrets      = var.app_secrets
  app_secret_names = nonsensitive(sort(keys(var.app_secrets)))
  app_secret_name  = "${local.scope_parsed}/${local.app_name}/${local.env}/app"
}

3. Os segredos vão para o Secrets Manager, write-only

O terraform/tf-secrets.tf cria um secret por região e grava o mapa inteiro como valor JSON dele. A versão usa um argumento write-only: o valor é enviado para a API e descartado, nunca cai no state do Terraform. O número da versão é derivado de um hash do conteúdo, então um valor alterado regrava o secret sem nenhum incremento manual, e um valor vazio quebra o plan em vez de iniciar um container com uma credencial vazia:

resource "aws_secretsmanager_secret" "app" {
  for_each = toset(local.secret_regions)
  region   = each.key
  name     = local.app_secret_name
}

resource "aws_secretsmanager_secret_version" "app" {
  for_each  = toset(local.secret_regions)
  region    = each.key
  secret_id = aws_secretsmanager_secret.app[each.key].id

  secret_string_wo         = jsonencode(local.app_secrets)
  secret_string_wo_version = parseint(substr(sha256(jsonencode(local.app_secrets)), 0, 8), 16)

  lifecycle {
    precondition {
      condition     = alltrue([for name in local.app_secret_names : local.app_secrets[name] != ""])
      error_message = "At least one SECRET_* Bitbucket variable is empty. ..."
    }
  }
}

O secret também recebe uma resource policy. A execution role já carrega secretsmanager:GetSecretValue na sua identity policy, então um Allow não restringiria nada; o que restringe o acesso a este único app é um Deny explícito para todos os outros. Esta é a policy real no aio/workload/dev/app em us-east-1:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyEveryoneElse",
    "Effect": "Deny",
    "Principal": { "AWS": "*" },
    "Action": "secretsmanager:GetSecretValue",
    "Resource": "*",
    "Condition": { "StringNotEquals": { "aws:PrincipalArn": [
      "arn:aws:iam::293174400224:role/aio-workload-use1-execution-role-dev",
      "arn:aws:iam::293174400224:role/deployer"
    ] } }
  }]
}

4. A task definition é montada a partir dos nomes

No terraform/tfmod-ecs-app.tf, as variáveis simples viram o environment do container e os nomes dos segredos viram o secrets dele, cada um apontando por ARN para sua chave no secret regional. O ECS rejeita uma task definition em que um nome é variável e segredo ao mesmo tempo, então qualquer ENV_X que colida com um SECRET_X é removida do ambiente: o segredo vence.

task_definition_environments = { for r in local.regions : r => merge(
  { for k, v in local.app_env : k => v if !contains(local.task_secret_names, k) },
  { APP_ENV_KEYS    = join(",", [for n in local.app_env_names : n if !contains(local.task_secret_names, n)]) },
  { for k, v in { APP_SECRET_KEYS = join(",", sort(local.task_secret_names)) } : k => v if length(local.task_secret_names) > 0 },
) }

task_definition_secrets = { for r in local.regions : r => [
  for name in local.app_secret_names : {
    name               = name
    secret_manager_arn = "arn:aws:secretsmanager:${r}:${local.aws_account_id}:secret:${local.app_secret_name}"
    secret_key         = name
  }
] }

Quando a task inicia, o agente do ECS, usando a execution role, chama GetSecretValue uma vez por secret regional, escolhe cada chave e entrega os valores ao processo como variáveis de ambiente. A aplicação não faz nenhuma chamada à AWS e não precisa de SDK para isso. O handler /secret inteiro do app de exemplo é:

names = [k for k in os.environ.get('APP_SECRET_KEYS', '').split(',') if k]
secrets = {name: os.environ.get(name) for name in names}
self.send_response(200 if names and all(secrets.values()) else 503)

Por que nada muda para adicionar ou remover uma

Cada etapa acima é guiada por dados. Os nomes vêm do ambiente, as variáveis do Terraform são map(string), os recursos iteram os mapas com for_each e jsonencode, e a versão do secret é indexada por um hash do conteúdo. Um novo SECRET_X no próximo push é mais uma chave no secret JSON, mais uma entrada em secrets, mais um nome em APP_SECRET_KEYS e uma nova task. Um removido some dos três. Sem nenhum SECRET_* restante, o próprio secret é destruído.

A prova está no repositório: nenhum de NOVA1, NOVA2, TESTEVAR, TESTSECRET ou CAMUFLADO aparece em qualquer arquivo do arglabs-workload. Eles existem só nas configurações do Bitbucket, e mesmo assim chegaram ao container.

Por que isso é uma boa prática

  • Uma única fonte de verdade por ambiente. As variáveis do repositório são a configuração. Não há arquivo .env, nem valor no código Terraform, nem valor no state do Terraform (write-only), nem segredo embutido na imagem.
  • A convenção é a lista de permissão. Nada chega ao container a menos que alguém o tenha nomeado de propósito com o prefixo. Credenciais da pipeline e variáveis do próprio Bitbucket são excluídas por construção, não por uma lista que precisa ser mantida.
  • Segredos são resolvidos pela plataforma, não pelo app. O agente do ECS os busca com a execution role, sob uma resource policy que nomeia exatamente dois principals. A aplicação lê variáveis de ambiente e nada mais, então continua portátil e não tem código de busca de segredos que possa errar.
  • Um nome é variável ou segredo, nunca os dois. Promover ENV_X a SECRET_X é só renomear no Bitbucket; o segredo vence na colisão, então não há nada para limpar antes.
  • Auditável. Toda leitura é um evento GetSecretValue no CloudTrail; o tf-secrets.tf os transforma em métricas, um alarme para leituras com falha e um dashboard por região. A task definition não guarda nenhum valor e pode ser comparada entre revisões.
  • O mesmo mecanismo em todo lugar. A Lambda de exemplo do mesmo repositório recebe os mesmos ENV_* como variáveis e lê o mesmo secret no cold start, a partir de APP_SECRET_ID e APP_SECRET_KEYS, com sua própria role na mesma policy.

Duas ressalvas honestas. O artefato do plan do Terraform que a pipeline publica carrega toda variável de root em texto claro, inclusive as sensitive, então qualquer pessoa que possa baixar os artefatos da pipeline pode ler os segredos; nada que precise permanecer privado pode viajar como TF_VAR. E os endpoints /env e /secret existem para mostrar o que chegou nesta aplicação de laboratório; uma aplicação real não deve responder com sua configuração, muito menos seus segredos, por HTTP.

Código: arglabs-workload (a aplicação, terraform/tf-secrets.tf e terraform/tfmod-ecs-app.tf) e arglabs-cloudteam-sharedpipelines (scripts/vars-appvars.sh).

Última atualização em