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.
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

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

Code Sample
Full working example: github.com/Moshiour027/techyowls-io-blog-public/github-actions-cicd
Summary
| Stage | Purpose | Trigger |
|---|---|---|
| CI | Test every change | Push, PR |
| Build | Create deployable artifact | Merge to main |
| Staging | Validate in prod-like env | Automatic |
| Production | Release to users | Tag + approval |
Golden rules:
- Every commit should be deployable
- Never deploy untested code
- Staging mirrors production
- Production requires approval
- Always have a rollback plan
From push to production in 10 minutes. That’s the goal.
Advertisement
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
GitHub Actions CI/CD Pipeline: Complete Tutorial
Build automated CI/CD pipelines with GitHub Actions. Learn workflows, jobs, actions, and deploy applications automatically with practical examples.
DevOpsDocker Compose for Spring Boot: Local Dev to Production
Containerize Spring Boot apps with Docker Compose. Multi-service setup, health checks, volumes, and production deployment patterns.
DevOpsDocker Best Practices for Production: Complete Guide
Master Docker best practices for production deployments. Learn image optimization, security hardening, multi-stage builds, and container orchestration.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.