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.

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."
fiO 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_XaSECRET_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
GetSecretValueno CloudTrail; otf-secrets.tfos 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 deAPP_SECRET_IDeAPP_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).