docker build && docker push desde tu portátil funciona hasta que otra persona tiene que publicar la misma imagen, o hasta que tiene que pasar en cada merge sin que tú estés delante del teclado. GitHub Actions ejecuta esa build en los runners de GitHub, etiqueta la imagen según lo que haya disparado el workflow y la publica en un registry desde el que tu paso de deploy puede hacer pull. Nada se ejecuta en local.

Qué hace falta para construir y publicar una imagen Docker desde CI

Cinco piezas tienen que estar en su sitio antes de que un runner pueda publicar una imagen:

  • actions/checkout para el contexto de build: tu Dockerfile y tu código fuente
  • docker/setup-buildx-action para un builder de BuildKit, porque el Docker Engine de serie del runner no puede exportar la caché de capas ni construir para otras plataformas
  • docker/login-action para las credenciales del registry
  • docker/metadata-action para convertir el ref de Git en una lista de tags y etiquetas OCI
  • docker/build-push-action para ejecutar la build y el push en una sola invocación

Docker mantiene las cinco, y están escritas para encajar entre sí: las salidas del paso de metadata entran directamente en las entradas del paso de build. Por dentro es el mismo docker build y docker push que ejecutas a mano, trasladado a un runner y conectado a eventos de Git.

Por qué el runner necesita Buildx antes de poder construir nada

ubuntu-latest trae Docker Engine, y Docker Engine construye imágenes sin problema con docker build. Lo que no puede hacer por su cuenta es exportar una caché de build a otro sitio que no sea el disco local, ni construir para una plataforma distinta a la del runner. docker/setup-buildx-action crea un builder Buildx respaldado por BuildKit y lo pone como predeterminado, que es lo que hace posibles cache-to/cache-from y las builds multiplataforma más adelante en esta guía.

1
2
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4

Un solo paso, sin entradas necesarias en el caso habitual. Si lo saltas, build-push-action sigue funcionando con el builder que el runner ya tenga — la build tiene éxito, pero la exportación de caché y el multi-arch quedan fuera de alcance.

Cómo autenticarse en GHCR con el token integrado

Cada ejecución del workflow recibe un GITHUB_TOKEN de corta duración, con el scope limitado al repositorio en el que corre. Para publicar en la GitHub Container Registry (ghcr.io), ese token es suficiente: sin personal access token, sin secret que crear ni rotar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
permissions:
  contents: read
  packages: write

steps:
  - name: Log in to GHCR
    uses: docker/login-action@v4
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}

El bloque permissions importa tanto como el paso de login. Sin packages: write, el token que GitHub le entrega al workflow puede leer pero no publicar, así que el login tiene éxito y el push falla con un 403. En un repositorio de organización hay un segundo sitio donde mirar: un ajuste de permisos de workflows en Settings → Actions → General puede limitar los tokens a solo lectura sin importar lo que pida el archivo del workflow. Si el push sigue fallando con el bloque de arriba ya en su sitio, revisa eso.

Cómo etiquetar imágenes automáticamente desde el ref de Git

Fijar un tag a mano en el archivo del workflow significa editar el YAML cada vez que sacas una release. docker/metadata-action lee el evento que disparó la ejecución (qué branch, qué tag, qué PR) y produce la lista de tags y las etiquetas OCI por ti.

1
2
3
4
5
6
7
8
9
- name: Extract metadata
  id: meta
  uses: docker/metadata-action@v6
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=semver,pattern={{version}}
      type=raw,value=latest,enable={{is_default_branch}}
      type=sha,format=short

Publicas un tag v1.4.0 y la regla semver produce 1.4.0. Haces push a main y la regla raw añade latest. Cada build recibe un tag basado en el short-SHA sin importar el disparador, así un commit concreto sigue siendo descargable incluso después de que latest haya avanzado más allá de él. steps.meta.outputs.tags es una lista de tags separados por saltos de línea que producen estas tres reglas para este único evento, no la unión de las tres a lo largo de todas las ejecuciones.

Cómo queda el workflow completo

Junta las piezas y añade docker/build-push-action, que ejecuta docker build y docker push en una sola llamada de BuildKit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: Build and push image

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}
            type=sha,format=short

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

context: . construye desde el repositorio recién clonado usando el Dockerfile que encuentra ahí; apunta file: a otra ruta si el tuyo está en otro sitio. Qué acaba dentro de la imagen sigue siendo cosa del Dockerfile, desde el orden de las capas hasta un USER sin root. Este workflow solo decide cuándo corre la build y a dónde va el resultado.

Algo que conviene resolver desde el principio: si la propia build necesita una credencial, como un token para un registry privado de npm, pásala por la entrada secrets: de la action en lugar de un build-arg, que queda visible en el historial de la imagen. Variables de Entorno y Secrets en Docker explica esa diferencia.

Cómo cachear capas entre ejecuciones del workflow

Un runner alojado por GitHub arranca limpio cada vez. Sin caché externa, cada capa se reconstruye en cada ejecución, RUN npm ci incluido. cache-from: type=gha y cache-to: type=gha,mode=max en el paso anterior le dicen a BuildKit que lea y escriba su caché a través del servicio de caché de GitHub Actions en lugar del disco local, así la caché sobrevive al runner.

mode=max cachea cada capa intermedia, incluidas las de etapas anteriores de un Dockerfile multi-stage. El valor por defecto, mode=min, guarda solo las capas que acaban en la imagen final, lo que significa que la etapa de build de un Dockerfile multi-stage se recompila desde cero en cada ejecución.

La caché de Actions tiene un límite de tamaño por repositorio y elimina las entradas más antiguas con lógica LRU. Para la mayoría de las imágenes de aplicación esto nunca llega a ser un problema. Un repositorio que construye varias imágenes multi-arch grandes puede alcanzarlo, y una caché respaldada por registry (cache-to: type=registry,ref=ghcr.io/org/app:buildcache) es la alternativa, porque no está limitada por la cuota de la caché de Actions.

Cómo construir en cada push pero publicar solo en release

El workflow de arriba publica en cada push a main y en cada tag de versión, que es el valor por defecto correcto para un proyecto que despliega desde main. Una configuración más conservadora construye y prueba cada pull request sin publicar nada, y publica solo tras un merge:

1
2
3
4
5
6
7
8
9
on:
  pull_request:
  push:
    branches: [main]
    tags: ["v*.*.*"]

# en el paso build-push-action:
with:
  push: ${{ github.event_name != 'pull_request' }}

La build completa sigue corriendo en la PR, así un Dockerfile roto falla antes del merge, mientras que push: false se salta la escritura en el registry. Con cache-to: type=gha ya en su sitio, esa build de la PR también prepara la caché que reutilizará el push tras el merge.

Qué cambia si publicas en Docker Hub en vez de GHCR

Docker Hub necesita un argumento de registry distinto y credenciales distintas: un access token desde Account Settings → Security, no la contraseña de tu cuenta, que Docker Hub ya no acepta para logins vía API.

1
2
3
4
5
6
7
8
- name: Log in to Docker Hub
  uses: docker/login-action@v4
  with:
    username: ${{ vars.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

# en la entrada images: de metadata-action pasa a ser:
images: docker.io/yourusername/app

La configuración de Buildx, la extracción de metadata y el paso de build-push no cambian. El registry es un parámetro, no un pipeline distinto.

Qué no cubre este workflow

Las imágenes multiplataforma (linux/amd64 más linux/arm64 en un solo manifest) necesitan docker/setup-qemu-action antes de Buildx y una entrada platforms: en el paso de build. La emulación QEMU hace que una build de arm64 en un runner amd64 sea lenta, a menudo varias veces más lenta que la nativa. Si construyes arm64 con regularidad, los runners arm64 alojados por GitHub eliminan la emulación por completo.

Los runners self-hosted que ya tienen Buildx configurado no necesitan setup-buildx-action, y un builder persistente ahí puede superar al backend de caché de Actions manteniendo las capas en disco local entre ejecuciones. Un monorepo que publica tres imágenes de servicio suele pasar a una matrix de build en vez de tres copias de este job, lo que cambia cómo se parametrizan context e images pero no los pasos en sí.

Prueba el workflow antes de etiquetar una release

Haz push a un branch primero, no a un tag de versión. Mira la ejecución en la pestaña Actions, luego revisa la imagen en la página Packages de tu repositorio, o haz pull directamente con docker pull ghcr.io/<owner>/<repo>:<short-sha> usando el tag del log de la ejecución. En cuanto ese pull funcione, una release es el mismo git tag v1.0.0 && git push --tags que ya usas, y el workflow se encarga del resto.

Artículos relacionados