Segredo na imagem Docker: como achar e tirar de vez
Segredo na imagem Docker testado: o token do ARG no history, o .env apagado que segue na camada, secret mount no build e /run/secrets com o dono certo.
Para ver o que uma imagem Docker carrega, montamos uma com três vícios que aparecem em projeto de verdade. Ela recebe o token do npm por ARG, define a senha do banco num ENV e faz COPY . . numa pasta com um .env, apagado logo depois com RUN rm -f .env. Os valores eram falsos, mas as marcas que ficaram são as mesmas de um projeto real. Rodando o container, o ls de /app mostra só o Dockerfile e o server.js, e o .env parece ter sumido. Não sumiu, e o token e a senha também estavam lá, legíveis para qualquer um com a imagem e sem acesso ao servidor.
Este é o roteiro que a Equipe DevBras usou para achar cada vazamento e o que fez cada um desaparecer, testado no Docker Engine 29.1.4, BuildKit 0.29.0 e Compose 5.6.1.
A imagem que vaza
FROM alpine:3.22 ARG NPM_TOKEN ENV DATABASE_URL=postgres://loja:SenhaDeProducao123@db/loja WORKDIR /app COPY . . RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && wc -c .npmrc && rm .npmrc RUN rm -f .env CMD ["sh", "-c", "ls -la /app"]
Construída com docker build --build-arg NPM_TOKEN=npm_tokenDeTeste777, ela parece limpa por dentro. O histórico conta outra história. Este é o trecho de docker history --no-trunc que contém os segredos:
|1 NPM_TOKEN=npm_tokenDeTeste777 /bin/sh -c rm -f .env |1 NPM_TOKEN=npm_tokenDeTeste777 /bin/sh -c echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && wc -c .npmrc && rm .npmrc /bin/sh -c #(nop) ENV DATABASE_URL=postgres://loja:SenhaDeProducao123@db/loja
O ARG foi parar em cada instrução RUN depois dele, inclusive numa que nem usa o token. O ENV aparece no histórico e também em docker image inspect, junto com o PATH. O rm .npmrc na mesma instrução funcionou para o arquivo, que não ficou em camada nenhuma, mas não adiantou nada para o token, que já estava no histórico. A documentação de segredos de build do Docker diz isso sem rodeio: build args e variáveis de ambiente não servem para segredo, porque persistem na imagem final.
O .env é o caso mais traiçoeiro. Extraindo as camadas com docker save, ele estava inteiro na camada do COPY . .. O RUN rm -f .env criou uma camada nova que só esconde o arquivo. Quem baixa a imagem baixa as duas.
Um script para procurar segredo em qualquer imagem
Antes de corrigir, descubra o que já vazou, porque cada segredo encontrado precisa ser trocado. O script recebe a imagem e alguns trechos dos seus segredos, como o começo do token ou um pedaço da senha, e procura nos três lugares: histórico, variáveis de ambiente e arquivos de cada camada, inclusive os apagados depois:
#!/bin/sh
# uso: ./verifica.sh imagem trecho1 [trecho2 ...]
IMG="$1"; shift
PADRAO=$(echo "$@" | tr ' ' '|')
echo "== history"
docker history --no-trunc --format '{{.CreatedBy}}' "$IMG" | grep -E "$PADRAO" || echo "(nada)"
echo "== env"
docker image inspect --format '{{json .Config.Env}}' "$IMG" | grep -E "$PADRAO" || echo "(nada)"
echo "== camadas"
TMP=$(mktemp -d)
docker save "$IMG" -o "$TMP/img.tar" && tar -xf "$TMP/img.tar" -C "$TMP"
for camada in "$TMP"/blobs/sha256/*; do
tar -tf "$camada" >/dev/null 2>&1 || continue
mkdir "$TMP/c" && tar -xf "$camada" -C "$TMP/c" 2>/dev/null
grep -rlaE "$PADRAO" "$TMP/c" | sed "s|$TMP/c|camada $(basename "$camada" | cut -c1-12): |"
rm -rf "$TMP/c"
done
rm -rf "$TMP"
Contra a imagem do teste, ele achou o token e a senha no histórico, a senha no ENV e dois arquivos com segredo na mesma camada: o .env e o próprio Dockerfile, que o COPY . . também levou e que tinha a senha escrita no ENV. Rode o script na imagem que está em produção, baixada do registry numa máquina que não tem acesso ao servidor. É a visão de quem conseguiu a imagem por fora.

O build que não grava nada
A versão corrigida tem duas peças. A primeira é um .dockerignore, para que arquivo sensível nem chegue ao build. Esta é a documentação do contexto de build:
.env .env.* *.pem *.key segredos/ .git
A segunda é o secret mount do BuildKit para o que o build realmente precisa, como o .npmrc com o token do registry de pacotes:
# syntax=docker/dockerfile:1
FROM alpine:3.22
WORKDIR /app
COPY server.js ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
wc -c /root/.npmrc && grep -c _authToken /root/.npmrc
COPY . .
CMD ["sh", "-c", "ls -la /app"]
No teste, o RUN enxergou o arquivo, que tinha 53 bytes e uma linha com _authToken. Com o Docker atual, o build fica assim:
docker build --secret id=npmrc,src=$HOME/.npmrc -t loja/api:2.3.0 .
Rodando o script de verificação na imagem nova, com os mesmos três trechos, nada apareceu no histórico, no ENV nem nas camadas. O ls de /app mostrou Dockerfile, .dockerignore e server.js, sem o .env. Num projeto Node de verdade, o RUN com o mount é onde entra o npm ci --omit=dev.
O segredo da aplicação chega quando o container sobe
A senha do banco não precisa existir no build. Ela entra quando o container sobe. Com Compose, um arquivo do servidor vira /run/secrets/database_url dentro do container, como mostra o guia de segredos no Compose:
services:
api:
image: loja/api:2.3.0
environment:
DATABASE_URL_FILE: /run/secrets/database_url
secrets:
- database_url
secrets:
database_url:
file: ./segredos/database_url.txt
Subimos dois serviços lado a lado, um com o segredo por arquivo e outro com a senha numa variável DATABASE_URL. No docker inspect, o segundo mostrou a senha inteira em Config.Env. O primeiro mostrou só o caminho DATABASE_URL_FILE=/run/secrets/database_url, e o arquivo apareceu como bind mount somente leitura. Variável de ambiente ainda é melhor que senha na imagem, mas qualquer pessoa com acesso ao Docker do servidor a lê, todo processo filho a herda, e ela costuma aparecer em relatório de erro que despeja o ambiente inteiro.
O teste também pegou uma armadilha de permissão. O arquivo do servidor tinha modo 600 e dono root, e o container o recebeu exatamente assim. Rodando como o usuário 1000, que é o node das imagens oficiais do Node, a leitura deu Permission denied. Depois de chown 1000:1000 no arquivo do servidor, mantendo o modo 600, a aplicação leu normalmente. Se a imagem tem USER node, o dono do arquivo de segredo no servidor precisa combinar com ele.
Do lado da aplicação, aceite as duas formas e falhe alto quando nenhuma existir:
const fs = require('fs');
function lerSegredo(nome) {
const arquivo = process.env[nome + '_FILE'];
if (arquivo) return fs.readFileSync(arquivo, 'utf8').trim();
if (process.env[nome]) return process.env[nome];
throw new Error('segredo ausente: ' + nome);
}
const databaseUrl = lerSegredo('DATABASE_URL');
No Node 24, a função leu o segredo pelo arquivo e pela variável. Sem nenhum dos dois, falhou com segredo ausente: DATABASE_URL. Com um caminho errado, falhou com ENOENT. É melhor o container não subir do que subir e tentar conectar sem senha.
A pasta segredos/ fica no servidor, fora do git. Quem sobe as stacks pelo Dockyard, o painel Docker da DevBras, usa o mesmo compose.yaml com a seção secrets, e o arquivo continua morando no host onde o agent roda.
Se a imagem com segredo já foi publicada
Apagar a tag do registry não resolve, porque você não sabe quem já puxou a imagem: servidores, CI, o notebook de quem testou. A ordem que funciona é:
- Troque o segredo na origem: token novo e senha nova no banco.
- Confirme que o valor antigo parou de funcionar.
- Publique a imagem corrigida e atualize os servidores.
- Só então remova as tags antigas.
O raciocínio é o mesmo de um segredo que vazou no git, e a troca sem derrubar quem usa segue a rotação em dois passos. Republicar com a mesma tag também não apaga nada, como mostra o teste de que a tag latest não é versão: a imagem antiga continua no cache de cada servidor que a baixou.
Referência: https://docs.docker.com/build/building/secrets/