DevOps 8 min read

GitHub Actions CI/CD: From Push to Production in 10 Minutes

Build a complete CI/CD pipeline for Spring Boot: automated tests, Docker builds, staging deployments, and production releases with approval gates.

MR

Moshiour Rahman

Advertisement

Why CI/CD Matters

Without CI/CD:

  • “It works on my machine” (but nowhere else)
  • Manual testing before every deploy
  • Deploy on Friday, fix on weekend
  • Fear of releasing

With CI/CD:

  • Every commit is tested
  • Every merge is deployable
  • Deploy on Friday, go home on time
  • Confidence in every release

The Pipeline We’ll Build

CI/CD Pipeline Flow

Project Structure

.github/
├── workflows/
│   ├── ci.yml              # Test on every push/PR
│   ├── build-deploy.yml    # Build and deploy on main
│   └── release.yml         # Production release
├── dependabot.yml          # Automated dependency updates
└── CODEOWNERS              # Required reviewers

Dockerfile
docker-compose.yml

Workflow 1: Continuous Integration

.github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

permissions:
  contents: read
  checks: write
  pull-requests: write

jobs:
  test:
    name: Build & Test
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Build with Maven
        run: ./mvnw -B verify
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
          SPRING_DATASOURCE_USERNAME: test
          SPRING_DATASOURCE_PASSWORD: test

      - name: Publish Test Results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Test Results
          path: '**/target/surefire-reports/*.xml'
          reporter: java-junit

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: target/site/jacoco/jacoco.xml
          fail_ci_if_error: false

  security-scan:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: OWASP Dependency Check
        run: ./mvnw org.owasp:dependency-check-maven:check
        continue-on-error: true  # Don't fail build, just report

      - name: Upload OWASP Report
        uses: actions/upload-artifact@v4
        with:
          name: owasp-report
          path: target/dependency-check-report.html

  code-quality:
    name: Code Quality
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for SonarQube

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: SonarCloud Scan
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
        run: |
          ./mvnw -B verify sonar:sonar \
            -Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} \
            -Dsonar.organization=${{ github.repository_owner }} \
            -Dsonar.host.url=https://sonarcloud.io
        continue-on-error: true  # Don't fail PR on quality issues

Workflow 2: Build & Deploy to Staging

.github/workflows/build-deploy.yml

name: Build & Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    name: Build Docker Image
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build.outputs.digest }}

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}

      - 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
          build-args: |
            VERSION=${{ github.sha }}

      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

  deploy-staging:
    name: Deploy to Staging
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.techyowls.io

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy to staging server
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            cd /opt/app
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            docker compose up -d --no-deps app
            docker image prune -f

      - name: Health check
        run: |
          for i in {1..30}; do
            if curl -sf https://staging.techyowls.io/actuator/health; then
              echo "Health check passed"
              exit 0
            fi
            echo "Waiting for service... ($i/30)"
            sleep 10
          done
          echo "Health check failed"
          exit 1

      - name: Run smoke tests
        run: |
          curl -sf https://staging.techyowls.io/api/health | jq .
          curl -sf https://staging.techyowls.io/api/version | jq .

      - name: Notify on Slack
        if: always()
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "text": "Staging deployment ${{ job.status }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Staging Deployment* ${{ job.status == 'success' && ':white_check_mark:' || ':x:' }}\n*Commit:* `${{ github.sha }}`\n*Author:* ${{ github.actor }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Workflow 3: Production Release

.github/workflows/release.yml

name: Production Release

on:
  push:
    tags:
      - 'v*.*.*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  release:
    name: Create Release
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get version
        id: version
        run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Generate changelog
        id: changelog
        uses: orhun/git-cliff-action@v2
        with:
          config: cliff.toml
          args: --current

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          body: ${{ steps.changelog.outputs.content }}
          draft: false
          prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'beta') }}

  deploy-production:
    name: Deploy to Production
    needs: release
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://techyowls.io

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Tag image for production
        run: |
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ needs.release.outputs.version }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ needs.release.outputs.version }}

      - name: Deploy to production
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/app

            # Backup current version
            docker tag app:current app:rollback || true

            # Pull new version
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ needs.release.outputs.version }}

            # Blue-green deployment
            docker compose up -d --no-deps --scale app=2 app
            sleep 30

            # Health check new instances
            if ! curl -sf http://localhost:8080/actuator/health; then
              echo "New version unhealthy, rolling back"
              docker compose up -d --no-deps app
              exit 1
            fi

            # Scale down old instances
            docker compose up -d --no-deps --scale app=1 app

      - name: Verify deployment
        run: |
          sleep 10
          VERSION=$(curl -sf https://techyowls.io/api/version | jq -r .version)
          if [ "$VERSION" != "${{ needs.release.outputs.version }}" ]; then
            echo "Version mismatch: expected ${{ needs.release.outputs.version }}, got $VERSION"
            exit 1
          fi

      - name: Notify team
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "text": ":rocket: Production deployment complete: v${{ needs.release.outputs.version }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

  rollback:
    name: Rollback (Manual)
    needs: deploy-production
    if: failure()
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Rollback deployment
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/app
            docker tag app:rollback app:current
            docker compose up -d --no-deps app
            echo "Rolled back to previous version"

      - name: Alert on rollback
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "text": ":warning: Production rolled back due to deployment failure"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Dockerfile: Multi-Stage Build

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

# Copy Maven files first (cache dependencies)
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN ./mvnw dependency:go-offline -B

# Copy source and build
COPY src ./src
RUN ./mvnw package -DskipTests -B

# Extract layers for better caching
RUN java -Djarmode=layertools -jar target/*.jar extract

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine

# Security: Run as non-root
RUN addgroup -S app && adduser -S app -G app
USER app

WORKDIR /app

# Copy layers in order of change frequency
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

# Build info
ARG VERSION=unknown
ENV APP_VERSION=$VERSION

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD wget -q --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Environment Protection Rules

# In GitHub repository settings → Environments → production

# Required reviewers
reviewers:
  - team/senior-engineers

# Wait timer
wait_timer: 10  # minutes

# Branch restrictions
deployment_branch_policy:
  protected_branches: true
  custom_branch_policies: false

# Environment secrets
secrets:
  - PROD_HOST
  - PROD_USER
  - PROD_SSH_KEY

Dependabot Configuration

.github/dependabot.yml

version: 2
updates:
  # Maven dependencies
  - package-ecosystem: "maven"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    open-pull-requests-limit: 5
    groups:
      spring:
        patterns:
          - "org.springframework*"
      testing:
        patterns:
          - "org.junit*"
          - "org.mockito*"
          - "org.testcontainers*"

  # GitHub Actions
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns:
          - "*"

  # Docker base images
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

Reusable Workflows

.github/workflows/deploy-template.yml

name: Deploy Template

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
    secrets:
      host:
        required: true
      ssh-key:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}

    steps:
      - name: Deploy
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.host }}
          username: deploy
          key: ${{ secrets.ssh-key }}
          script: |
            docker pull ${{ inputs.image-tag }}
            docker compose up -d

Using the template

jobs:
  deploy-staging:
    uses: ./.github/workflows/deploy-template.yml
    with:
      environment: staging
      image-tag: ghcr.io/myorg/myapp:${{ github.sha }}
    secrets:
      host: ${{ secrets.STAGING_HOST }}
      ssh-key: ${{ secrets.STAGING_SSH_KEY }}

Matrix Builds for Multiple Java Versions

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java: [17, 21]
        include:
          - java: 21
            experimental: false
          - java: 17
            experimental: false

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: 'temurin'

      - name: Test
        run: ./mvnw -B verify
        continue-on-error: ${{ matrix.experimental }}

Pipeline Visualization

Pipeline Timeline

Code Sample

Full working example: github.com/Moshiour027/techyowls-io-blog-public/github-actions-cicd

Summary

StagePurposeTrigger
CITest every changePush, PR
BuildCreate deployable artifactMerge to main
StagingValidate in prod-like envAutomatic
ProductionRelease to usersTag + approval

Golden rules:

  1. Every commit should be deployable
  2. Never deploy untested code
  3. Staging mirrors production
  4. Production requires approval
  5. Always have a rollback plan

From push to production in 10 minutes. That’s the goal.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.