Contenido

Tutorial de CI/CD con GitHub Actions y Docker: construye y publica imágenes automáticamente

En Despliegue en un servidor en la nube construyes, publicas y actualizas el servidor a mano. En esta sección lo automatizas con GitHub Actions:

  • Cada push a main: construye y publica la imagen automáticamente, con la etiqueta main.
  • Al crear una etiqueta de versión (por ejemplo, v1.2.0): construye imágenes amd64 + arm64, publica etiquetas como 1.2.0 y 1.2, y luego entra por SSH en el servidor y lo actualiza.
  • Pull requests: construye sin publicar, para comprobar que el Dockerfile es válido.
Flujo desde git push hasta la compilación en GitHub Actions, la publicación en ghcr.io y la actualización del servidor
Flujo de CI/CD: push del código → compilación en GitHub Actions → ghcr.io → el servidor descarga la versión nueva

Estructura del proyecto

Usa el proyecto de Node.js de Despliegue en un servidor en la nube y añade un archivo de workflow:

myapp/
├── .github/
│   └── workflows/
│       └── docker.yml
├── Dockerfile
├── package.json
├── package-lock.json
└── server.js

GitHub ejecuta automáticamente todos los archivos YAML de .github/workflows/.

El workflow completo

.github/workflows/docker.yml:

name: Build and push image

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

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Check Dockerfile
        run: docker build --check .

      - uses: docker/setup-qemu-action@v4

      - uses: docker/setup-buildx-action@v4

      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Decide image tags
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          build-args: APP_VERSION=${{ github.ref_name }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - name: Update the server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /srv/myapp
            VERSION='${{ github.ref_name }}'
            sed -i "s/^APP_VERSION=.*/APP_VERSION=${VERSION#v}/" .env
            docker compose pull app
            docker compose up -d app

Explicación paso a paso

Disparadores y permisos

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

permissions:
  contents: read
  packages: write
  • Se ejecuta con cada push a la rama main, con cada push de etiquetas que empiecen por v y con los pull requests.
  • packages: write permite que el GITHUB_TOKEN proporcionado automáticamente publique en ghcr.io. Conceder solo los permisos necesarios es una buena práctica.

Revisar antes de construir

      - name: Check Dockerfile
        run: docker build --check .

El runner ubuntu-latest de GitHub ya tiene Docker instalado. docker build --check revisa la sintaxis del Dockerfile (la presentación está en docker init). Las advertencias no hacen fallar la compilación, pero aparecen en los registros. Este es el resultado de revisar en local el Dockerfile de este proyecto:

$ docker build --check . 2>&1 | tail -n 1
Check complete, no warnings found.

QEMU y Buildx

      - uses: docker/setup-qemu-action@v4
      - uses: docker/setup-buildx-action@v4

El runner de GitHub es amd64. Para construir una imagen arm64 cuando el Dockerfile tiene pasos que ejecutan programas, como RUN npm ci, necesitas la emulación de QEMU (el motivo está en Imágenes multiplataforma). setup-buildx-action crea un builder compatible con varias plataformas y con la exportación de caché.

Iniciar sesión en el registry

      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

Los pull requests no publican imágenes, así que no necesitan iniciar sesión (además, los PR externos no reciben permisos de publicación). No tienes que crear secrets.GITHUB_TOKEN tú mismo.

Para publicar en Docker Hub, primero crea un Access Token en Docker Hub y guárdalo en tu repositorio de GitHub en Settings → Secrets and variables → Actions; después usa:

        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

(Si omites registry, se usa Docker Hub). Cambia también images en metadata-action a tu-cuenta/myapp.

Elegir automáticamente las etiquetas de la imagen

docker/metadata-action genera las etiquetas según el evento que disparó el workflow:

DisparadorEtiquetas generadas
Push a mainmain, sha-1a2b3c4
Push de la etiqueta v1.2.01.2.0, 1.2, sha-1a2b3c4
Pull request #7pr-7, sha-1a2b3c4 (solo compila, no publica)
  • type=semver solo genera etiquetas si la etiqueta de Git sigue el versionado semántico (como v1.2.0), y quita la v inicial.
  • type=sha usa los 7 primeros caracteres del hash del commit, para saber fácilmente qué versión del código construyó la imagen.
  • También genera las etiquetas estándar de OCI (repositorio de origen, commit y fecha de creación), que build-push-action escribe en la imagen.

Construir y publicar

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          build-args: APP_VERSION=${{ github.ref_name }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
ParámetroDescripción
platformsConstruye para dos arquitecturas; al publicarlas forman una sola imagen multiplataforma
pushfalse en los pull requests, que así solo compilan
build-argsCorresponde a ARG APP_VERSION del Dockerfile, para que la aplicación pueda mostrar su propia versión
tags / labelsUsa la salida del paso anterior de metadata-action
cache-from / cache-to: type=ghaGuarda la caché de compilación en la caché de GitHub Actions. mode=max guarda también las capas intermedias, así que si la próxima vez solo cambias el código, la capa de npm ci se reutiliza directamente

Despliegue automático

  deploy:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
  • needs: build: solo se ejecuta si la compilación tiene éxito.
  • if: solo despliega cuando publicas una etiqueta de versión. Un push a main no afecta a producción.

appleboy/ssh-action entra en el servidor por SSH y ejecuta script. El script contiene los pasos que hacías a mano en Despliegue en un servidor en la nube: actualizar APP_VERSION en .env y ejecutar pull y up -d. ${{ github.ref_name }} es el nombre de la etiqueta (v1.2.0), y ${VERSION#v} quita la v inicial. Este es el resultado de probar ese comando sed en local:

$ VERSION='v1.2.0'; sed -i "s/^APP_VERSION=.*/APP_VERSION=${VERSION#v}/" .env; cat .env
APP_VERSION=1.2.0
DB_PASSWORD=secret

Define estos tres valores en los Secrets de tu repositorio de GitHub:

SecretValor
DEPLOY_HOSTIP o dominio del servidor
DEPLOY_USERUsuario de SSH
DEPLOY_SSH_KEYClave privada SSH exclusiva para despliegues (pon la clave pública correspondiente en ~/.ssh/authorized_keys del servidor)

Crea un usuario y una clave exclusivos para los despliegues. No uses la clave con la que entras tú en el servidor.

Validar el workflow con actionlint

Si un workflow está mal escrito, no lo sabrás hasta subirlo a GitHub, y cada corrección lleva tiempo. La herramienta de código abierto actionlint revisa la sintaxis y las expresiones en local. Ejecútala en un contenedor para no tener que instalarla:

$ docker run --rm -v "$(pwd):/repo" -w /repo rhysd/actionlint:latest -color=false -verbose .github/workflows/docker.yml
verbose: Linting .github/workflows/docker.yml
verbose: Found 0 parse errors in 1 ms for .github/workflows/docker.yml
verbose: Found total 0 errors in 13 ms for .github/workflows/docker.yml

0 errors significa que no hay problemas. Escribe mal github.ref_name a propósito, como github.ref_nam:

$ docker run --rm -v "$(pwd):/repo" -w /repo rhysd/actionlint:latest -color=false .github/workflows/docker.yml
.github/workflows/docker.yml:52:39: property "ref_nam" is not defined in object type {action: string; action_path: string; action_ref: string; action_repository: string; action_status: string; actor: string; actor_id: string; api_url: string; artifact_cache_size_limit: number; base_ref: string; env: string; event: object; event_name: string; event_path: string; graphql_url: string; head_ref: string; job: string; output: string; path: string; ref: string; ref_name: string; ref_protected: bool; ref_type: string; repository: string; repository_id: string; repository_owner: string; repository_owner_id: string; repository_visibility: string; repositoryurl: string; retention_days: number; run_attempt: string; run_id: string; run_number: string; secret_source: string; server_url: string; sha: string; state: string; step_summary: string; token: string; triggering_actor: string; workflow: string; workflow_ref: string; workflow_sha: string; workspace: string} [expression]
   |
52 |           build-args: APP_VERSION=${{ github.ref_nam }}
   |                                       ^~~~~~~~~~~~~~

actionlint indica que ref_nam no existe en la línea 52 y lista todas las propiedades disponibles del objeto github. Es posible que GitHub no informe de este tipo de error y lo convierta en silencio en una cadena vacía.

El workflow de este capítulo pasó la validación de actionlint, pero no se subió realmente a GitHub (eso crearía un paquete en ghcr.io y entraría por SSH en un servidor real). La primera vez que lo ejecutes en tu repositorio, revisa los registros de cada paso en la página Actions.

Configuración en el servidor

En el compose.yaml del servidor, cambia la imagen a la dirección de ghcr.io:

  app:
    image: ghcr.io/yourname/myapp:${APP_VERSION}

Los paquetes nuevos de ghcr.io son privados por defecto, así que inicia sesión una vez en el servidor (con un Personal Access Token con el permiso read:packages):

echo "tu token" | docker login ghcr.io -u yourname --password-stdin

--password-stdin lee la contraseña de la entrada estándar, así que no aparece en el historial de comandos.

Publicar una versión nueva

Con todo configurado, publicar una versión nueva solo requiere estos comandos:

git tag v1.2.0
git push origin v1.2.0

GitHub Actions hará, en orden: revisar el Dockerfile → construir para amd64 y arm64 → publicar ghcr.io/yourname/myapp:1.2.0 y 1.2 → entrar por SSH en el servidor y cambiar APP_VERSION a 1.2.0 → descargar y recrear el contenedor app.

Para volver atrás, cambia APP_VERSION a la versión anterior en el servidor y ejecuta docker compose up -d app. La imagen antigua sigue disponible en ghcr.io.

Resumen

  • Pon los workflows en .github/workflows/ y usa on para elegir cuándo se ejecutan.
  • Las Actions oficiales de Docker son setup-qemu, setup-buildx, login, metadata y build-push. Juntas construyen y publican imágenes multiplataforma.
  • Publica en ghcr.io con el GITHUB_TOKEN proporcionado automáticamente; recuerda packages: write.
  • metadata-action genera automáticamente las etiquetas de la imagen según la rama, las etiquetas de versión y los PR.
  • cache-from/to: type=gha acelera las compilaciones repetidas.
  • El job de despliegue solo se ejecuta con etiquetas de versión: actualiza .env por SSH y ejecuta pull y up -d.
  • Revisa antes el workflow en local con actionlint.

Ejercicios

Ejercicio 1: modifica el workflow para que un push a main construya solo para amd64 (más rápido) y una etiqueta de versión construya para las dos plataformas.

Respuesta

También puedes usar una expresión en platforms:

          platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}

Después del cambio, revísalo con actionlint.

Ejercicio 2: ¿qué pasa si creas una etiqueta llamada release-1 (que no empieza por v)?

Respuesta

No pasa nada. on.push.tags solo coincide con v*, así que release-1 no dispara el workflow. La condición if del job deploy también acepta solo etiquetas que empiecen por refs/tags/v.

Ejercicio 3: revisa con actionlint los archivos de workflow de tu propio proyecto y comprueba si hay errores que se te hayan pasado.

Preguntas frecuentes

¿GitHub Actions cuesta dinero?

Los runners alojados por GitHub son gratuitos para los repositorios públicos. Los repositorios privados tienen una cuota gratuita mensual y solo pagas lo que la supere. Consulta la documentación oficial de GitHub para ver las cuotas y precios actuales.

¿Por qué no necesito crear una contraseña aparte para publicar en ghcr.io?

GitHub proporciona automáticamente un GITHUB_TOKEN temporal cada vez que se ejecuta un workflow. Añade packages: write a los permissions del workflow para usarlo y publicar en ghcr.io bajo la misma cuenta u organización.

El servidor recibe unauthorized al descargar una imagen de ghcr.io. ¿Qué hago?

Los paquetes nuevos de ghcr.io son privados por defecto. En el servidor, ejecuta docker login ghcr.io con un Personal Access Token que tenga el permiso read:packages, o haz público el paquete en su configuración de GitHub.

¿Qué hago si las compilaciones multiplataforma son lentas?

En los runners x86 de GitHub, arm64 se emula con QEMU, así que los pasos que compilan tardan mucho más. Puedes construir arm64 solo al crear una etiqueta de versión, usar cache-from: type=gha o construir por separado en runners arm64. Para lenguajes con compilación cruzada como Go y Rust, consulta [Imágenes multiplataforma](/es/docker/multi-platform/).

Actualizado 13 de octubre de 2026