CI/CD con GitHub Actions: Despliega tu App Spring Boot en Producción Automáticamente

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:

FaseTiempo esperado
Checkout + setup JDK~30s
Descarga de dependencias (con caché)~15s
Tests unitarios1 – 3 min
Tests de integración2 – 5 min
Build imagen Docker (con caché de capas)1 – 2 min
Push al registry30s – 1 min
Deploy SSH30 – 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.