A DevOps project built around an existing Spring Boot 3.2 / Java 21 REST API. The application itself is not the focus — the goal was to build a complete CI/CD pipeline around it using Jenkins, deploy it automatically to AWS EC2, and provision the entire infrastructure with Terraform and Ansible.
The Spring Boot application was used as a realistic deployment target. The work covered here is the pipeline, the infrastructure, the containerization, and the automation — not the backend code itself.
The Spring Boot application (Users and Contacts CRUD API) was taken as-is and used as a realistic deployment target. The objective of this project was to build everything around it:
- CI/CD with Jenkins — pipeline triggered on every GitHub push: build → transfer → deploy → health check
- Infrastructure as Code — Terraform provisions all AWS resources from scratch; Ansible configures the server
- Containerization — Docker Compose running the app and PostgreSQL 16 with health checks and restart policies
- Secret management — no credentials in code or git history; everything flows through Jenkins credentials
- One-command provisioning —
./aws/setup-infra.shchains Terraform + Ansible and leaves a server ready to receive deployments
| Layer | Technology |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 3.2 |
| Database | PostgreSQL 16 |
| ORM | Spring Data JPA / Hibernate |
| Security | Spring Security Crypto (BCrypt), custom API Key filter |
| API Docs | SpringDoc OpenAPI 2.3 / Swagger UI |
| Build | Maven Wrapper |
| Containerization | Docker, Docker Compose |
| CI/CD | Jenkins (runs in Docker locally) |
| Infrastructure | Terraform, Ansible |
| Cloud | AWS EC2, Elastic IP, S3 |
GitHub Push
│
▼
Jenkins (Docker, localhost:8080)
│
├─ 1. Checkout ─── clone via GitHub token
├─ 2. Build ─── Maven → store-0.0.1-SNAPSHOT.jar
├─ 3. Prepare ─── generate deploy.sh
├─ 4. Transfer ─── SCP → JAR + Dockerfile + docker-compose.yml
└─ 5. Deploy ─── SSH → docker compose down && up --build
│
▼
AWS EC2 (Ubuntu 22.04)
│
┌─────────┴──────────┐
│ Docker Compose │
├────────────────────-┤
│ app → port 8081 │
│ db → PostgreSQL │
└────────────────────-┘
Terraform provisions:
- EC2
t2.micro(Ubuntu 22.04 LTS, dynamic Canonical AMI) - Elastic IP — fixed public address attached to the instance
- Security Group — SSH restricted to your IP, port 8081 open
- S3 bucket — remote Terraform state with native locking (
use_lockfile) and AES-256 encryption - RSA 4096 key pair — generated by Terraform, stored as a Jenkins credential
Ansible configures (on the EC2 after provisioning):
- Docker CE + Compose plugin (official Docker repository)
- Docker auto-start at boot via systemd
ubuntuuser added to thedockergroup
The Jenkinsfile defines a 5-stage pipeline triggered automatically on every GitHub push via webhook:
| Stage | What it does |
|---|---|
| Checkout | Clones the repository using a GitHub token credential |
| Build | ./mvnw clean package — runs tests, then produces the JAR |
| Prepare Deployment | Generates deploy.sh (docker compose down && up -d --build) |
| Transfer Files | SCP the JAR, Dockerfile, docker-compose.yml, and .env to EC2 |
| Deploy to Staging | SSH-executes deploy.sh, waits 30s, then hits /actuator/health (expects HTTP 200) |
Jenkins credentials required:
| Credential ID | Type | Used for |
|---|---|---|
github-token-id |
Secret text | GitHub repository clone |
server-ip-id |
Secret text | EC2 IP (auto-updated by setup-infra.sh) |
aws-ec2-pem |
SSH private key | SCP and SSH access to EC2 |
env-file-id |
Secret file | .env file transferred at deploy time |
See JENKINS_SETUP.md for initial Jenkins configuration (plugins, credentials, GitHub webhook).
All three tools solve the same problem — automating build, test, and deployment on code changes. The choice depends on the context.
Jenkins
| Advantages | Disadvantages |
|---|---|
| Self-hosted: full control over the execution environment | Requires a server to run on (or a local machine) |
| No build minute limits | Initial setup takes time (plugins, credentials, configuration) |
| Works on any infrastructure, including air-gapped or on-premise networks | Maintenance overhead: updates, security patches |
| Agnostic: works with GitHub, GitLab, Bitbucket, or any Git server | Groovy DSL has a learning curve |
| 1800+ plugins, deep integration with enterprise tools (JIRA, SonarQube, Artifactory, LDAP) | UI feels dated compared to modern alternatives |
| Programmatic pipelines with Groovy — better suited for complex conditional logic | |
| Widely adopted in large enterprises — strong career value |
GitHub Actions
| Advantages | Disadvantages |
|---|---|
| Zero infrastructure to set up or maintain | Free tier limited to 2000 min/month on private repos, paid beyond that |
| Native integration directly in the GitHub repository | Tightly coupled to GitHub — harder to migrate |
| Large marketplace of ready-made actions | Less control over the execution environment |
| Simple YAML syntax | Complex pipelines become verbose in YAML |
| Free for public repositories | Runner customization requires self-hosted runners |
GitLab CI
| Advantages | Disadvantages |
|---|---|
| Fully integrated with GitLab (code, issues, registry, pipeline in one place) | Only relevant if the codebase is hosted on GitLab |
| Clean and readable YAML syntax | Same YAML complexity limits as GitHub Actions at scale |
| Built-in container registry | Requires GitLab as the hosting platform |
| Free shared runners included |
Why Jenkins for this project
The goal was to learn how to set up a CI/CD pipeline from scratch — Jenkins is the right tool for that because it requires you to understand each piece explicitly: where it runs, how credentials are managed, how the pipeline is structured, how webhooks work. GitHub Actions or GitLab CI abstract most of that away, which is convenient in production but less instructive when learning. Jenkins is also the most common tool in enterprise environments, making it a valuable skill regardless of the project.
Requires: Terraform, Ansible, and AWS CLI configured with an account that has EC2, S3, and IAM permissions (aws configure).
Everything is provisioned with a single command from your local machine:
./aws/setup-infra.shThis chains automatically: Terraform → PEM copy → EC2 IP sync into Jenkins → Ansible inventory → SSH wait → Ansible playbook. The server is ready to receive deployments when the script finishes.
To tear everything down:
./aws/destroy-infra.sh # prompts for confirmation before destroyingWhat setup-infra.sh does, step by step:
| Step | Action |
|---|---|
| 1 | terraform apply on backend-setup → S3 bucket |
| 2 | terraform apply on main → EC2, Elastic IP, Security Group, key pair |
| 3 | Copy PEM to ~/.ssh/springboot-api.pem with chmod 600 |
| 4 | Retrieve Elastic IP via terraform output |
| 5 | Create or update server-ip-id credential in Jenkins via REST API |
| 6 | Generate aws/ansible/inventory.ini |
| 7 | Wait for SSH to be available on the instance (polls every 10s) |
| 8 | Run ansible-playbook install.yml to configure Docker on the server |
See aws/README.md for the full infrastructure breakdown.
The Terraform state is stored remotely in S3 (versioning + AES-256 encryption) and locked via S3 native locking to prevent concurrent deployments — see aws/README.md for a detailed explanation of this pattern.
All endpoints under /api/* require the X-API-KEY header. Swagger UI is publicly accessible at /swagger-ui/index.html.
Requires: Docker Desktop (or Docker Engine), Java 21, Maven.
cp .env.example .env
# Edit .env: fill in DB_NAME, DB_USER, DB_PASSWORD, API_KEY
./mvnw clean package -DskipTests # build the JAR first — required by the Dockerfile
docker compose up --buildThe
Dockerfilecopies the JAR fromtarget/— it does not compile the code itself. The./mvnw clean packagestep must run beforedocker compose up --build, otherwise the build will fail with a missing file error.
The API will be available at http://localhost:8081.
Swagger UI: http://localhost:8081/swagger-ui/index.html
Requires: Docker Desktop (or Docker Engine). Jenkins itself runs inside a container — nothing else to install. To receive GitHub webhooks while running locally, expose port 8080 with ngrok (ngrok http 8080).
./jenkins_local/jenkins_start.sh # starts Jenkins at http://localhost:8080
./jenkins_local/jenkins_stop.sh # stops Jenkins
./jenkins_local/jenkins_logs.sh # tails logsJenkins data (plugins, credentials, pipeline configuration, build history) is stored in a Docker volume named jenkins-data. Stopping and restarting the container with jenkins_stop.sh / jenkins_start.sh preserves everything — no need to reconfigure Jenkins from scratch between sessions.
.
├── src/ # Spring Boot application source
├── aws/
│ ├── terraform/ # EC2, EIP, Security Group, key pair, S3 backend
│ ├── ansible/install.yml # Docker CE installation and configuration on EC2
│ ├── setup-infra.sh # One-command provisioning (Terraform + Ansible)
│ └── destroy-infra.sh # One-command teardown
├── jenkins_local/ # Scripts to run Jenkins in Docker locally
├── Jenkinsfile # CI/CD pipeline definition
├── Dockerfile # App image (eclipse-temurin:21-jre-jammy)
├── docker-compose.yml # App + PostgreSQL services
├── JENKINS_SETUP.md # Jenkins initial configuration guide
└── .env.example # Environment variable template
No secrets are hardcoded or committed to the repository. Each secret lives in the layer that owns it:
| Secret | How it's handled |
|---|---|
App credentials (.env) |
Jenkins secret file credential (env-file-id), SCP'd to EC2 at deploy time |
| EC2 PEM key | Generated by Terraform, stored as Jenkins SSH credential (aws-ec2-pem), in .gitignore |
| GitHub token | Jenkins credential (github-token-id), injected via withCredentials, never printed in logs |
| EC2 IP | Jenkins credential (server-ip-id), auto-updated by setup-infra.sh after each terraform apply |
SSH access uses the PEM key (no password). ssh-keyscan populates known_hosts before each connection — no StrictHostKeyChecking=no. The Security Group restricts SSH (port 22) to your IP only; ports 80 and 443 are not open.
This project focuses on the CI/CD pipeline and the infrastructure automation. Several layers could be added to make it production-grade — they were intentionally left out because the goal was to learn Jenkins, not to build a complete production setup.
The application is currently exposed directly on port 8081 over plain HTTP. In a real setup, you would put a reverse proxy in front of it:
- BunkerWeb — an nginx-based web application firewall (WAF) that blocks common attacks (SQLi, XSS, bad bots, brute force) out of the box. Drop-in replacement for a standard nginx setup with security built in.
- Nginx + Fail2ban — a lighter alternative: nginx as a reverse proxy with Fail2ban watching the logs and automatically banning IPs that trigger too many failed requests.
- Let's Encrypt — free TLS certificates, auto-renewed, easily integrated via Certbot with either of the above.
Instead of exposing the raw EC2 IP, you would point a domain at the Elastic IP and manage DNS through a provider like Cloudflare. Beyond DNS, Cloudflare also acts as a CDN and adds another layer of DDoS protection and bot filtering in front of the origin server.
In this project, Jenkins runs locally in Docker on your own machine. This is fine for learning but has obvious limitations: your machine needs to be on and reachable from GitHub for webhooks to work.
The natural next step would be to run Jenkins on its own dedicated EC2 instance, provisioned the same way as the application server (Terraform + Ansible), with BunkerWeb or Nginx in front to protect the Jenkins UI from the internet.
This was deliberately left out for one practical reason: cost. Running a permanent EC2 instance for Jenkins (even a t2.micro) adds to the AWS bill every hour. For a personal learning project, keeping Jenkins local avoids unnecessary spending while still covering everything the pipeline needs to do.
Currently the Docker image is built directly on the EC2 server at each deployment (docker compose up --build). This means the production server does the build — it pulls the source code, compiles, and constructs the image every time.
The proper approach is to build the image once in Jenkins, push it to a registry, and have the server only pull the already-built image:
Jenkins build JAR
│
▼
Jenkins build Docker image
│
▼
Push image → registry (AWS ECR, Docker Hub, GitHub Container Registry)
│
▼
EC2: docker pull image:v1.2 && docker compose up
This has several advantages: the server no longer needs build tools, every image gets a unique tag (commit SHA, build number), and rollback becomes trivial — just pull a previous tag.
There is currently no rollback mechanism. If the health check fails, the deployment stops, but the previous containers are already down. The server is left in a broken state.
With an image registry and tagged images, rollback is straightforward: the pipeline stores the previous image tag, and if the health check fails it automatically re-deploys the last known good version. Without a registry, an alternative is to keep a copy of the previous JAR on the server and re-run docker compose up with it if the deployment fails.
The application currently receives its secrets through a .env file transferred via SCP and stored on disk on the EC2 instance. Two AWS-native alternatives remove the need for this file entirely:
- AWS Secrets Manager — secrets (database credentials, API keys) are stored centrally in AWS and retrieved by the application at runtime via the AWS SDK. Nothing is stored on disk. Secrets can be rotated without redeploying.
- AWS Systems Manager Parameter Store — a lighter alternative for non-sensitive configuration values. Free for standard parameters, with optional encryption via KMS for sensitive ones.
Both approaches also eliminate the need to transfer a .env file through Jenkins, since the EC2 instance retrieves its own secrets directly from AWS using its IAM role.
This project deploys to a single environment. In a real setup, you would have at least two: staging and production, each on its own EC2 instance, with the pipeline deploying to staging first and promoting to production only after the health check passes.
push to main
│
▼
Jenkins build + tests
│
▼
Deploy → EC2 staging → health check ✓
│
▼
Deploy → EC2 production → health check ✓
The Terraform structure would mirror this with separate directories per environment (aws/terraform/staging/, aws/terraform/production/), each with their own state, variables, and EC2 instance. Jenkins would hold separate credentials for each (server-ip-staging, server-ip-prod) and separate .env files.
This was left out for the same reason as the dedicated Jenkins server: cost. A second EC2 instance running permanently doubles the infrastructure bill. For a learning project focused on Jenkins, a single environment is sufficient to cover everything the pipeline needs to demonstrate.
aws/README.md— Terraform resources, Ansible playbook, setup and destroy scripts in detailJENKINS_SETUP.md— Jenkins plugins, credentials setup, GitHub webhook configuration