Onde acabam as variáveis de ambiente de um container

Suba um container com -e DB_PASSWORD=hunter2 e rode docker inspect nele:

1
2
docker run -d --name api -e DB_PASSWORD=hunter2 nginx:alpine
docker inspect --format '{{json .Config.Env}}' api
1
["DB_PASSWORD=hunter2","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]

A senha fica ali em texto plano, legível por qualquer um com acesso ao socket do Docker, por docker exec api env e por qualquer processo rodando dentro do container. Isso não é um bug — é exatamente para isso que as variáveis de ambiente existem. O problema é usá-las para valores que nunca deveriam ficar tão visíveis.

Onde definir uma variável de ambiente no Docker: build time vs runtime

Quatro mecanismos, três ciclos de vida. Onde você define uma variável decide por quanto tempo ela dura e quem consegue lê-la de volta:

MecanismoDefinida ondeFica na imagem?Visível depois de docker inspect?
ARG no DockerfileSó no buildNão (a menos que seja copiada para ENV)Não, mas fica registrada no histórico de build
ENV no DockerfileNo buildSim, embutida em cada camada seguinteSim
-e / --env no docker runAo iniciar o containerNãoSim
--env-file no docker runAo iniciar o containerNãoSim

Em tempo de execução você as passa uma a uma, ou a partir de um arquivo:

1
2
3
4
5
# Uma a uma, repetível
docker run -e NODE_ENV=production -e PORT=3000 my-api

# A partir de um arquivo, um KEY=VALUE por linha, sem aspas
docker run --env-file .env.production my-api

.env.production tem essa cara:

1
2
3
NODE_ENV=production
PORT=3000
LOG_LEVEL=info

--env-file passa a ser a melhor opção assim que você tem mais de duas ou três variáveis: mantém o comando de execução legível e os valores em um único lugar que dá para comparar com um diff.

Variáveis de ambiente no Docker Compose: environment, env_file e .env

O Compose tem três lugares para colocar variáveis, e dois deles são arquivos com nomes quase idênticos. .env e env_file: fazem trabalhos completamente diferentes.

  • .env na raiz do projeto é lido pelo próprio Compose, para substituir os placeholders ${VARIAVEL} dentro de compose.yaml. Nunca chega ao container a menos que você também o referencie em environment:.
  • env_file: lista arquivos cujo conteúdo é injetado no ambiente do container, exatamente como --env-file no docker run.
  • environment: define variáveis diretamente no arquivo Compose, inline.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

Quando uma variável é definida em mais de um lugar, o Compose a resolve nesta ordem, da maior prioridade para a menor: um -e passado ao docker compose run, depois environment:, depois env_file:, depois o ENV que já vem embutido na imagem. Se NODE_ENV aparece tanto em environment: quanto em .env.api, vence o valor de environment:.

Por que variáveis de ambiente não são o lugar certo para secrets

Nenhum dos mecanismos acima esconde um valor de quem tem acesso ao container ou ao host:

  • docker inspect imprime cada variável em tempo de execução, como mostrado acima.
  • docker exec <container> env as imprime de dentro.
  • Um processo filho herda todo o ambiente, incluindo um debugger, um crash reporter ou uma dependência que você não auditou.
  • Qualquer coisa que despeje seu ambiente ao iniciar manda o valor para o seu agregador de logs, e um número surpreendente de frameworks faz exatamente isso quando o debug logging está ativo.
  • Variáveis de ambiente definidas no build com ENV são permanentes: docker history --no-trunc my-api mostra o valor exato, e ele fica na imagem em todo registry para onde você a envia.

Nada disso exige que um atacante comprometa o container. Basta o acesso que muita gente já tem: o socket do Docker, os logs da CI, o registry de imagens.

Como funcionam os secrets do Docker: o valor é montado como arquivo

Pare de entregar credenciais ao container como variáveis de ambiente e monte-as como arquivos. O Compose faz isso sozinho, sem precisar de Swarm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

O Compose monta db_password.txt em /run/secrets/db_password dentro do container, somente leitura e em nenhum outro lugar: nunca aparece no docker inspect, no docker exec ... env, nem na imagem. O sufixo _FILE é uma convenção que as imagens oficiais do Postgres, MySQL e MongoDB já suportam: ao iniciar, o script de entrypoint lê o arquivo em vez de esperar o valor diretamente.

Se sua aplicação não suporta essa convenção, leia o arquivo você mesmo ao iniciar: uma linha na maioria das linguagens, por exemplo open('/run/secrets/db_password').read().strip() em Python.

Em um cluster Swarm, o secret equivalente é criado e distribuído pelo orquestrador em vez de um arquivo local:

1
2
echo "supersecret" | docker secret create db_password -
docker service create --name db --secret db_password postgres:16

O Swarm guarda o secret criptografado em repouso e em trânsito, e o monta num filesystem em memória em cada réplica: ele nunca é escrito na camada gravável do container.

Mantendo secrets fora da imagem no build

ARG e ENV num Dockerfile têm o mesmo problema um passo antes: um valor passado como argumento de build fica registrado no histórico de build da imagem mesmo que você nunca o transforme numa ENV.

1
2
3
FROM node:20-slim
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
2
docker build --build-arg NPM_TOKEN=npm_abc123 -t my-api .
docker history --no-trunc my-api | grep npm_abc123

Esse comando docker history encontra o token. Ele some do filesystem final se você não fizer COPY do arquivo de configuração adiante, mas fica legível para sempre nos metadados da imagem, que viajam com ela para todo registry.

A flag --secret do BuildKit evita isso montando o valor em uma única etapa RUN, como um arquivo em memória que nunca vira camada:

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
docker buildx build --secret id=npm_token,src="$HOME/.npm_token" -t my-api .

npm_token existe só durante essa instrução RUN e não fica registrado em lugar nenhum que o docker history consiga ver.

Quando usar uma variável de ambiente e quando usar um secret

SituaçãoUse
Configuração não sensível (porta, log level, feature flag)-e, --env-file, ou environment:/env_file: do Compose
Senha de banco de dados, API key, chave TLS em tempo de execuçãoUm secret do Docker, montado como arquivo
Token de autenticação necessário só durante docker build--secret do BuildKit com RUN --mount=type=secret
Valor embutido na imagem de propósito (versão do app, commit de build)ARG copiado para ENV — tudo bem, não é secret

Uma pergunta decide tudo: você se incomodaria se esse valor vazasse? Se sim, ele não passa por -e, --env-file, environment:, nem por um ARG/ENV no Dockerfile. Ele passa por um secret montado como arquivo. As configurações que seu app imprime de boa nos próprios logs continuam sendo variáveis de ambiente normais, mais fáceis de sobrescrever por ambiente.

Então vá conferir. Rode docker inspect --format '{{json .Config.Env}}' nos containers que você tem rodando agora, e leia a saída como se fosse o diff de um pull request. Qualquer coisa ali que você não gostaria de ver revisada em público deve ir para um bloco secrets: no seu arquivo Compose, com uma variável _FILE no lugar da senha. Se o arquivo precisa sobreviver ao container em vez de vir do contexto de build, coloque-o num volume Docker.

Artigos relacionados