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:
| Phase | Expected time |
|---|---|
| Checkout + JDK setup | ~30s |
| Dependency download (with cache) | ~15s |
| Unit tests | 1 – 3 min |
| Integration tests | 2 – 5 min |
| Docker image build (with layer cache) | 1 – 2 min |
| Push to registry | 30s – 1 min |
| SSH deploy | 30 – 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.