Guía10 min

Build secrets Docker para un agente IA: el token no va en el layer

Resumen

Cómo instalar un paquete privado en el build del agente sin hornear el token: RUN --mount=type=secret, --secret id= en Buildx y secrets/secret-envs/secret-files en GitHub Actions. Distinto de Compose secrets (runtime) y de .dockerignore. Default /run/secrets/<id>, required=true.

DockerGitHub
Capas de build planas con una franja de secret mount y un recorte omit

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

ARG NPM_TOKEN y ENV TELEGRAM_TOKEN sobreviven en docker history. Un COPY .npmrc . mete el token en un layer aunque lo borres después. El build del agente necesita secretos (registry privado, Sentry auth, AWS para un aws s3 cp de un modelo). La vía correcta no es ARG: es un secret mount de BuildKit que vive solo durante ese RUN y no se persiste en la imagen.

Esta guía cubre el build. El runtime va en Compose secrets. No copiar .env al contexto va en dockerignore. Qué es un secreto y cómo rotarlo, en secretos y variables.

La regla

El Dockerfile declara el id. El cliente expone el valor con --secret. El RUN lo lee de un archivo (default /run/secrets/<id>) o de una env del paso, no de la imagen. Si el valor no llega y pones required=true, el build falla cerrado. Si no pones required, el mount vacío es un silencio caro: npm ci contra el registry público y un agente a medias.

Necesitas # syntax=docker/dockerfile:1 arriba del Dockerfile. Sin frontend moderno, --mount=type=secret no existe.

Qué monta BuildKit

Opciones oficiales de RUN --mount=type=secret (Dockerfile reference, consultada el 7 de septiembre de 2026):

OpciónDefaultPara qué
idbasename del targetEl mismo id que pasas a --secret
target / dst / destination/run/secrets/<id> si no hay envPath del archivo en el contenedor de build
env(off)Carga el secreto a una variable solo en ese RUN (desde Dockerfile v1.10.0)
requiredfalseFalla si el secreto no está
mode0400Permisos del archivo
uid / gid0Dueño del archivo

El valor no queda en el layer. En Linux, mode 0400 + uid del usuario del RUN evita lecturas extra. En Windows no hay /run/secrets/ por defecto: target explícito.

Ejemplo: npm privado en el build del agente

El agente instala un SDK interno. El token no puede ir al blob.

# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=secret,id=npm_token,required=true \
  sh -c 'printf "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)\n" > /tmp/.npmrc \
  && pnpm install --frozen-lockfile --config.npmrc=/tmp/.npmrc \
  && rm -f /tmp/.npmrc'

Cliente local:

docker buildx build --secret id=npm_token,src="$HOME/.npm_token" .

Buildx detecta type=file si no hay env con el mismo nombre que id. Si NPM_TOKEN está en el entorno y el id es NPM_TOKEN, detecta type=env. Explícito siempre:

docker buildx build --secret id=npm_token,env=NPM_TOKEN .

No uses ARG + echo $NPM_TOKEN > .npmrc. Ese string entra al historial del layer.

ARG vs secret vs Compose

MecanismoVive enSale en docker historyUso
ARG / --build-argmetadata del buildsí (y provenance max)flags no secretos (NODE_ENV de build)
ENVimagen finalconfig pública del runtime
RUN --mount=type=secretsolo ese RUNnotoken de registry, clave de firma
Compose secrets:contenedor runtimeno (si no lo echoas)token del bot en producción

Compose secrets no alimentan el build. Un docker compose build sin --secret deja el RUN --mount=type=secret vacío. Si el agente se construye en CI y corre en un VPS, son dos inyecciones: esta guía para la imagen, Compose secrets para el proceso.

El multi-stage no salva un leak: COPY --from=deps copia lo que ya quedó en deps. El secret mount tiene que estar en el RUN que consume el token, no “en el stage” como idea vaga.

Diagrama de un secreto montado solo durante el RUN de build

GitHub Actions: tres inputs, un id

La página oficial Using secrets with GitHub Actions (verificada el 7 de septiembre de 2026) separa de dónde sale el valor (input de docker/build-push-action) de cómo lo consume el Dockerfile (RUN --mount).

Input de la actionFuenteEquivalente Buildx
secrets: ID=valuestring del workflow--secret id=ID,src=<temp>
secret-envs: ID=ENV_VARenv del runner--secret id=ID,env=ENV_VAR
secret-files: ID=./filearchivo en el runner--secret id=ID,src=./file

Patrón mínimo con GITHUB_TOKEN (el id del mount es github_token; el valor viene del contexto secrets):

- uses: docker/build-push-action@v7
  with:
    tags: user/agente:latest
    secrets: |
      github_token=${{ secrets.GITHUB_TOKEN }}
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=github_token,env=GITHUB_TOKEN \
  wget --header="Authorization: Bearer $GITHUB_TOKEN" ...

secret-envs cuando un step previo ya exportó la variable:

- uses: docker/build-push-action@v7
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
  with:
    secret-envs: |
      sentry_token=SENTRY_AUTH_TOKEN

secret-files para un .npmrc o .pypirc ya formateado. GitHub Docs: no pases secretos por argv (ps los ve); usa env, STDIN o el mount. Un secret no seteado en Actions es string vacío, no error: por eso required=true en el Dockerfile.

Forks no reciben secrets (salvo GITHUB_TOKEN limitado). Un PR de fork que construye el agente no debe asumir NPM_TOKEN. Para la nube, OIDC evita una clave eterna.

Lo que el mount no cubre

  • Logs. cat /run/secrets/npm_token en el Dockerfile imprime el token. Actions enmascara ${{ secrets.* }} en logs; no enmascara lo que tú echo desde el archivo montado. Cero set -x alrededor del cat.
  • Cache de BuildKit. El resultado del RUN sí se cachea. El secreto no va en la clave como texto, pero un layer cacheado con node_modules de un registry privado es el artefacto, no el token. No “invalides cache pegando el token al comando”.
  • Provenance. --build-arg de un token sale en attestations modo max. El secret mount no. Si publicas SBOM/attestations, ARG de credenciales es un leak distinto al del layer.
  • SSH. RUN --mount=type=ssh es otro tipo (agente o clave). No lo mezcles con type=secret ni con clonar por HTTPS + token en la URL.
  • Runtime del bot. El Telegram token no se “build-sea”. Se monta en el contenedor que corre. Mezclarlos es el incidente clásico: imagen pública con el bot token en un layer “temporal” que no lo era.

Flujo CI: secret del workflow al mount del RUN, nunca al tag publicado

Checklist

  1. Primera línea: # syntax=docker/dockerfile:1.
  2. Cada RUN que habla con un registry privado usa --mount=type=secret,id=…,required=true.
  3. Default path /run/secrets/<id> o env= en el mismo RUN. Nunca ENV TOKEN=.
  4. Cliente: docker buildx build --secret id=…,src=… o env=…. Compose build no basta.
  5. CI: secrets / secret-envs / secret-files de build-push-action; el id coincide con el Dockerfile.
  6. grep del Dockerfile: cero ARG de tokens, cero COPY .npmrc, cero echo $TOKEN.
  7. Tras el build: docker history y docker run --rm imagen cat /run/secrets/npm_token deben fallar (el path no existe en runtime).
  8. Token del bot: Compose secrets o el secret store de Fly/Railway, no este mount.

FAQ

¿Puedo usar el mismo id en build y en Compose? Sí como nombre. No es el mismo mecanismo. BuildKit lo monta en el contenedor de build; Compose, en el de run.

¿env= deja el token en la imagen? No. env del mount vale solo para ese RUN. ENV del Dockerfile sí queda. No los confundas.

¿Y si el agente es serverless (Vercel/Workers)? No hay Dockerfile. El secreto de build es el de la plataforma (env de build), no un mount. Esta guía es para imagen Docker del webhook/worker self-hosted.

¿El secret file en el runner queda en el workspace? secret-files apunta a un path que creaste. Bórralo en el mismo job. secrets: inline lo escribe Buildx a un temp y no lo deja en el checkout.

Siguiente paso

Pon required=true en el RUN que instala dependencias privadas y deja de pasar --build-arg del token. Si el agente aún no tiene imagen, el curso instalar un agente arma el runtime; el hub seguridad, coste y operación agrupa el resto del deploy.