Un pipeline de CI/CD bien configurado es la diferencia entre un equipo que despliega con miedo cada viernes y un equipo que despliega varias veces al día con confianza. En este tutorial construimos un pipeline completo con GitHub Actions para una aplicación Spring Boot: desde el commit hasta producción, con tests, Docker y despliegue automático.
Arquitectura del pipeline
git push → GitHub Actions trigger
│
├── Test job
│ ├── Compilar con Gradle
│ ├── Tests unitarios
│ ├── Tests de integración
│ └── Informe de cobertura
│
├── Build job (solo en main/master)
│ ├── Build imagen Docker
│ └── Push a Container Registry
│
└── Deploy job (solo en main/master)
├── SSH al servidor de producción
├── Pull nueva imagen
└── Restart contenedor
Estructura del proyecto
mi-app/
├── .github/
│ └── workflows/
│ └── ci-cd.yml ← El pipeline
├── src/
├── Dockerfile
├── docker-compose.prod.yml
└── build.gradle.kts
Paso 1: El Dockerfile optimizado
Antes del pipeline, necesitas un Dockerfile correcto. Usamos multi-stage build y layered JARs:
# ── Stage 1: Build ───────────────────────────────────────────
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# ── Stage 2: Extract layers ──────────────────────────────────
FROM eclipse-temurin:21-jre-alpine AS layers
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# ── Stage 3: Production image ─────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /app
COPY --from=layers /app/dependencies/ ./
COPY --from=layers /app/spring-boot-loader/ ./
COPY --from=layers /app/snapshot-dependencies/ ./
COPY --from=layers /app/application/ ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"org.springframework.boot.loader.launch.JarLauncher"]
Paso 2: Configurar los Secrets en GitHub
En tu repositorio: Settings → Secrets and variables → Actions
DOCKER_USERNAME # Usuario de Docker Hub o tu registry
DOCKER_PASSWORD # Token de acceso (no la contraseña)
PROD_HOST # IP o dominio del servidor de producción
PROD_USER # Usuario SSH del servidor
PROD_SSH_KEY # Clave privada SSH (sin passphrase)
PROD_PORT # Puerto SSH (por defecto 22)
Para generar la clave SSH específica para CI/CD:
# En tu máquina local
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
# La clave pública va al servidor de producción
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys # En el servidor
# La clave privada va al secret de GitHub
cat ~/.ssh/deploy_key # Copia este contenido al secret PROD_SSH_KEY
Paso 3: El workflow completo
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master]
env:
IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/mi-app
JAVA_VERSION: '21'
jobs:
# ── Job 1: Tests ─────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: 'temurin'
cache: 'gradle'
- name: Grant execute permission to gradlew
run: chmod +x gradlew
- name: Run tests
run: ./gradlew test --no-daemon
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
- name: Upload test results
uses: actions/upload-artifact@v4
if: always() # Sube aunque fallen los tests
with:
name: test-results
path: build/reports/tests/
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: success()
with:
name: coverage-report
path: build/reports/jacoco/
# ── Job 2: Build & Push Docker image ─────────────────────────
build:
name: Build & Push
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable=true
type=raw,value=${{ github.run_number }}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max # Cache entre builds — reduce tiempo ~60%
# ── Job 3: Deploy to production ───────────────────────────────
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
environment: production # Requiere aprobación manual si lo configuras
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_PORT }}
script: |
set -e
echo "Pulling new image..."
docker pull ${{ env.IMAGE_NAME }}:${{ github.run_number }}
echo "Stopping current container..."
docker stop mi-app || true
docker rm mi-app || true
echo "Starting new container..."
docker run -d \
--name mi-app \
--restart unless-stopped \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=${{ secrets.DB_URL }} \
-e SPRING_DATASOURCE_PASSWORD=${{ secrets.DB_PASS }} \
--health-cmd "wget -qO- http://localhost:8080/actuator/health || exit 1" \
--health-interval 30s \
--health-retries 3 \
${{ env.IMAGE_NAME }}:${{ github.run_number }}
echo "Waiting for health check..."
sleep 15
docker inspect --format='{{.State.Health.Status}}' mi-app
echo "Cleaning old images..."
docker image prune -f
echo "Deploy completed successfully ✓"
Paso 4: Variables de entorno seguras en producción
Nunca pases secrets directamente en el script de deploy. Usa un archivo .env en el servidor o Docker secrets:
# En el servidor de producción — crea el archivo de env una sola vez
cat > /opt/mi-app/.env << EOF
SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/prod
SPRING_DATASOURCE_USERNAME=produser
SPRING_DATASOURCE_PASSWORD=contraseña_segura
SPRING_PROFILES_ACTIVE=prod
EOF
chmod 600 /opt/mi-app/.env
# Modifica el deploy para usar el archivo
docker run -d \
--name mi-app \
--env-file /opt/mi-app/.env \
...
Paso 5: Despliegue sin downtime con zero-downtime deploy
El script anterior tiene una pequeña ventana de downtime entre docker stop y el arranque del nuevo contenedor. Para eliminarlo:
# Zero-downtime con nginx y dos contenedores alternos
BLUE_PORT=8081
GREEN_PORT=8082
CURRENT=$(docker inspect --format='{{.HostConfig.PortBindings}}' mi-app-blue 2>/dev/null && echo "blue" || echo "green")
if [ "$CURRENT" = "blue" ]; then
NEW_NAME="mi-app-green"
NEW_PORT=$GREEN_PORT
OLD_NAME="mi-app-blue"
else
NEW_NAME="mi-app-blue"
NEW_PORT=$BLUE_PORT
OLD_NAME="mi-app-green"
fi
# Arranca el nuevo contenedor
docker run -d --name $NEW_NAME -p $NEW_PORT:8080 ${{ env.IMAGE_NAME }}:latest
# Espera a que esté sano
until [ "$(docker inspect --format='{{.State.Health.Status}}' $NEW_NAME)" = "healthy" ]; do
sleep 5
done
# Cambia el tráfico en nginx
sed -i "s/127.0.0.1:[0-9]*/127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/mi-app.conf
nginx -s reload
# Elimina el contenedor antiguo
docker stop $OLD_NAME && docker rm $OLD_NAME
Métricas del pipeline
Un pipeline bien configurado debería tener estos tiempos aproximados:
| Fase | Tiempo esperado |
|---|---|
| Checkout + setup JDK | ~30s |
| Descarga de dependencias (con caché) | ~15s |
| Tests unitarios | 1 – 3 min |
| Tests de integración | 2 – 5 min |
| Build imagen Docker (con caché de capas) | 1 – 2 min |
| Push al registry | 30s – 1 min |
| Deploy SSH | 30 – 60s |
| Total | ~6 – 12 min |
Si tu pipeline tarda más de 15 minutos, hay oportunidades de optimización: caché de dependencias, paralelización de tests o split en módulos.
Conclusión
Un pipeline CI/CD con GitHub Actions elimina el riesgo de los despliegues manuales, documenta implícitamente el proceso de build y despliegue, y permite que el equipo se mueva rápido con confianza. El workflow de este artículo es un punto de partida sólido: adaptable a múltiples entornos, extensible con notificaciones (Slack, email) y fácil de auditar.
Si necesitas ayuda para configurar la infraestructura de CI/CD de tu proyecto, cuéntanos tu caso.