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 etiquetamain. - Al crear una etiqueta de versión (por ejemplo,
v1.2.0): construye imágenes amd64 + arm64, publica etiquetas como1.2.0y1.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.
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 porvy con los pull requests. packages: writepermite que elGITHUB_TOKENproporcionado 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:
| Disparador | Etiquetas generadas |
|---|---|
Push a main | main, sha-1a2b3c4 |
Push de la etiqueta v1.2.0 | 1.2.0, 1.2, sha-1a2b3c4 |
| Pull request #7 | pr-7, sha-1a2b3c4 (solo compila, no publica) |
type=semversolo genera etiquetas si la etiqueta de Git sigue el versionado semántico (comov1.2.0), y quita lavinicial.type=shausa 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ámetro | Descripción |
|---|---|
platforms | Construye para dos arquitecturas; al publicarlas forman una sola imagen multiplataforma |
push | false en los pull requests, que así solo compilan |
build-args | Corresponde a ARG APP_VERSION del Dockerfile, para que la aplicación pueda mostrar su propia versión |
tags / labels | Usa la salida del paso anterior de metadata-action |
cache-from / cache-to: type=gha | Guarda 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 amainno 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:
| Secret | Valor |
|---|---|
DEPLOY_HOST | IP o dominio del servidor |
DEPLOY_USER | Usuario de SSH |
DEPLOY_SSH_KEY | Clave 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 usaonpara elegir cuándo se ejecutan. - Las Actions oficiales de Docker son
setup-qemu,setup-buildx,login,metadataybuild-push. Juntas construyen y publican imágenes multiplataforma. - Publica en ghcr.io con el
GITHUB_TOKENproporcionado automáticamente; recuerdapackages: write. metadata-actiongenera automáticamente las etiquetas de la imagen según la rama, las etiquetas de versión y los PR.cache-from/to: type=ghaacelera las compilaciones repetidas.- El job de despliegue solo se ejecuta con etiquetas de versión: actualiza
.envpor SSH y ejecutapullyup -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