CI/CD with GitHub Actions: Deploy Your Spring Boot App to Production Automatically

A well-configured CI/CD pipeline is the difference between a team that deploys with fear every Friday and a team that deploys multiple times a day with confidence. In this tutorial we build a complete pipeline with GitHub Actions for a Spring Boot application: from commit to production, with tests, Docker and automatic deployment.

Pipeline Architecture

git push → GitHub Actions trigger

    ├── Test job
    │   ├── Compile with Gradle
    │   ├── Unit tests
    │   ├── Integration tests
    │   └── Coverage report

    ├── Build job (main/master only)
    │   ├── Build Docker image
    │   └── Push to Container Registry

    └── Deploy job (main/master only)
        ├── SSH to production server
        ├── Pull new image
        └── Restart container

Project Structure

my-app/
├── .github/
│   └── workflows/
│       └── ci-cd.yml       ← The pipeline
├── src/
├── Dockerfile
├── docker-compose.prod.yml
└── build.gradle.kts

Step 1: The Optimised Dockerfile

Before the pipeline, you need a correct Dockerfile. We use multi-stage build and 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"]

Step 2: Configure GitHub Secrets

In your repository: Settings → Secrets and variables → Actions

DOCKER_USERNAME      # Docker Hub user or your registry
DOCKER_PASSWORD      # Access token (not the password)
PROD_HOST            # Production server IP or domain
PROD_USER            # SSH user on the server
PROD_SSH_KEY         # Private SSH key (no passphrase)
PROD_PORT            # SSH port (default 22)

Generate a dedicated SSH key for CI/CD:

# On your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

# Public key goes to the production server
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys  # On the server

# Private key goes to the GitHub secret
cat ~/.ssh/deploy_key  # Copy this content to the PROD_SSH_KEY secret

Step 3: The Complete Workflow

# .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 }}/my-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()
        with:
          name: test-results
          path: build/reports/tests/

  # ── 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 }}

    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
            type=raw,value=${{ github.run_number }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max  # Layer cache — reduces build time ~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

    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 my-app || true
            docker rm my-app || true

            echo "Starting new container..."
            docker run -d \
              --name my-app \
              --restart unless-stopped \
              -p 8080:8080 \
              --env-file /opt/my-app/.env \
              --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}}' my-app

            echo "Cleaning old images..."
            docker image prune -f

            echo "Deploy completed successfully ✓"

Step 4: Secure Environment Variables in Production

Never pass secrets directly in the deploy script. Use an .env file on the server:

# On the production server — create the env file once
cat > /opt/my-app/.env << EOF
SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/prod
SPRING_DATASOURCE_USERNAME=produser
SPRING_DATASOURCE_PASSWORD=secure_password
SPRING_PROFILES_ACTIVE=prod
EOF

chmod 600 /opt/my-app/.env

Pipeline Metrics

A well-configured pipeline should have these approximate times:

PhaseExpected time
Checkout + JDK setup~30s
Dependency download (with cache)~15s
Unit tests1 – 3 min
Integration tests2 – 5 min
Docker image build (with layer cache)1 – 2 min
Push to registry30s – 1 min
SSH deploy30 – 60s
Total~6 – 12 min

If your pipeline takes more than 15 minutes, there are optimisation opportunities: dependency caching, test parallelisation or module splitting.

Conclusion

A CI/CD pipeline with GitHub Actions eliminates the risk of manual deployments, implicitly documents the build and deployment process, and allows the team to move fast with confidence. The workflow in this article is a solid starting point: adaptable to multiple environments, extensible with notifications (Slack, email) and easy to audit.

If you need help setting up the CI/CD infrastructure for your project, tell us your case.