This is a complete backend for a Job Portal System using Spring Boot microservices, service discovery, centralized configuration, API Gateway, JWT authentication, Redis caching, RabbitMQ, Zipkin tracing, Prometheus/Grafana/Loki monitoring, SonarQube code-quality analysis, and Gmail SMTP notifications.
config-server(port8888)discovery-server/ Eureka (port8761)api-gateway(port8080)auth-service(port8081)job-service(port8082)application-service(port8083)admin-service(port8084)notification-service(port8085)
Frontend -> API Gateway -> Microservices
auth-servicehandles register/login and JWT generationjob-servicehandles recruiter job posting and searchapplication-servicehandles applying and status updatesadmin-serviceprovides admin views/reportsnotification-serviceconsumes RabbitMQ events and sends emails
Service registration and routing are handled with Eureka + Gateway.
- Java 21
- Spring Boot 3.3.x
- Spring Cloud 2023.x
- Gradle multi-module
- MySQL
- Redis
- RabbitMQ
- Zipkin
- Prometheus
- Grafana
- Loki
- SonarQube
- Gmail SMTP
Configured database names:
auth-service->auth_dbapplication-service->user_dbjob-service->job_dbadmin_dbandnotification_dbare already created by you and kept available for future extensions
MySQL credentials used in config:
- username:
root - password:
smit
Before running services, start:
- MySQL (with your databases created)
- RabbitMQ on default port
5672
Run from project root C:\Users\smitm\Downloads\jobsportalgradle.
java -version
docker --version
docker compose version
.\gradlew --versionExpected: Java 21 and Gradle wrapper output should work.
powershell -ExecutionPolicy Bypass -File .\infrastructure\start-local.ps1powershell -ExecutionPolicy Bypass -File .\infrastructure\stop-local.ps1- Use
powershell.exe, notpwsh, ifpwshis not installed. - Correct Gradle module is
discovery-server(noteureka-server). - Eureka dashboard:
http://localhost:8761 - API Gateway:
http://localhost:8080
This checks that SMIT.mshome.net is no longer present in registry data:
(Invoke-RestMethod 'http://localhost:8761/eureka/apps' | ConvertTo-Json -Depth 20) | Select-String 'SMIT\.mshome\.net'If command returns no output, hostname resolution issue is fixed.
If you want to run service-by-service manually, use this order and commands:
.\gradlew :services:config-server:bootRun
.\gradlew :services:discovery-server:bootRun
.\gradlew :services:auth-service:bootRun
.\gradlew :services:job-service:bootRun
.\gradlew :services:application-service:bootRun
.\gradlew :services:admin-service:bootRun
.\gradlew :services:notification-service:bootRun
.\gradlew :services:api-gateway:bootRunOpen each command in a separate terminal window if you run all at once.
Invoke-WebRequest http://localhost:8888/actuator/health -UseBasicParsing
Invoke-WebRequest http://localhost:8761 -UseBasicParsing
Invoke-WebRequest http://localhost:8080/actuator/health -UseBasicParsingIf these pass, test API routing via gateway:
Invoke-WebRequest http://localhost:8080/api/jobs -UseBasicParsingdocker compose up --build -dOn first run, MySQL auto-creates auth_db, job_db, and user_db from infrastructure/mysql-init/01-create-databases.sql.
docker compose ps
docker compose logs -f api-gateway
docker compose logs -f discovery-server- Eureka:
http://localhost:8761 - Zipkin:
http://localhost:9411 - Prometheus:
http://localhost:9090 - Loki:
http://localhost:3100/ready - Grafana:
http://localhost:3000(default login:admin/admin) - SonarQube:
http://localhost:9000
What was added
spring-boot-starter-cacheandspring-boot-starter-data-redisin business services using cache- Redis cache config with JSON serializer for safer object serialization
- Cache annotations in:
job-service(getAllJobs,getJob,search, evict oncreateJob)auth-service(getAllUsers,getAllUserEmails, evict onregister)admin-service(users,jobs,reports)notification-service(cached email list fetch viaAuthUserEmailService)
Why
- Reduces repeated DB and cross-service read load
- Improves response latency for high-read endpoints
- Keeps write paths simple and data freshness controlled by TTL + targeted evictions
What was added
- Tracing dependencies in all services:
io.micrometer:micrometer-tracing-bridge-braveio.zipkin.reporter2:zipkin-reporter-brave
- Zipkin endpoint configuration in centralized Config Server files
- Local fallback tracing configuration in service-local
application.ymlfiles zipkinservice indocker-compose.yml
Why
- Lets you follow one request across gateway + downstream microservices
- Makes root-cause analysis faster for
401/403/500and latency issues - Improves observability of async flows (RabbitMQ producer/consumer chains)
What was added
io.micrometer:micrometer-registry-prometheusin all services- Actuator exposure for
health,info,prometheus prometheusservice with scrape config (infrastructure/prometheus/prometheus.yml)grafanaservice with provisioned Prometheus datasource
Why
- Prometheus stores service metrics over time (RPS, latency, JVM, errors)
- Grafana visualizes trends and helps capacity/performance tuning
- Complements Zipkin: metrics show what is wrong; traces show where
What was added
lokiandpromtailservices indocker-compose.yml- Loki datasource provisioning for Grafana in
infrastructure/grafana/provisioning/datasources/loki.yml - Promtail config files:
infrastructure/promtail/promtail-docker.yml(Docker logs + optional host log files)infrastructure/promtail/promtail-windows.yml(Windows host log files)
- File logging configured per service via Config Server (
logging.file.name: logs/<service>.log)
Why
- Lets you search logs across all services from Grafana Explore using LogQL
- Makes debugging
401/403/500easier by correlating logs with Prometheus metrics and Zipkin traces - Supports both Docker-based and localhost service runs
What was added
- SonarQube + PostgreSQL services in
docker-compose.yml - Root Gradle Sonar plugin in
build.gradle
Why
- Continuous static analysis for bugs, code smells, maintainability and security hotspots
- Gives quality gates before merges/releases
What was added
auth-service: multipart OTP registration request support onPOST /api/auth/register/request-otpfor optionalprofileImageapplication-service: multipart apply support onPOST /api/applicationsfor resume file upload- Cloudinary config keys in Config Server:
cloudinary.cloud-namecloudinary.api-keycloudinary.api-secret- folders under
cloudinary.folders.*
Why
- Avoids storing image/resume binary data inside service databases
- Gives stable hosted URLs for profile images and resumes
- Keeps existing JSON APIs backward-compatible while enabling file uploads
CQRS in job-service
- Write path uses
JobCommandService(createJob+ cache eviction + event publish) - Read path uses
JobQueryService(list/get/search + Redis caching) - New paginated endpoint:
GET /api/jobs/paged?page=0&size=10&title=java&location=pune
Why
- Read/write responsibilities are separated for easier optimization
- Pagination avoids loading large job lists in one response
- Read-heavy traffic benefits from Redis cache without complicating write logic
Saga optimization in application-service
updateStatusnow creates a saga record (application_status_saga) withPENDING- On successful RabbitMQ publish, state becomes
COMPLETED - On publish failure, state becomes
FAILEDwith retry metadata for debugging/replay - Event includes
eventIdandcorrelationId
Idempotent consumer in notification-service
- Deduplicates status-notification events by
eventIdusing Redis (setIfAbsentwith TTL) - Prevents duplicate emails on retries/redeliveries
config-server now supports Git-backed configuration via environment variables.
Default remains native (local classpath config). Switch to Git with:
$env:CONFIG_SERVER_PROFILE="git"
$env:CONFIG_GIT_URI="https://github.com/<your-org>/<your-config-repo>.git"
$env:CONFIG_GIT_DEFAULT_LABEL="main"
$env:CONFIG_GIT_USERNAME="<optional-username>"
$env:CONFIG_GIT_PASSWORD="<personal-access-token-or-password>"
$env:CONFIG_GIT_CLONE_ON_START="true"Then start config-server and verify:
Invoke-WebRequest http://localhost:8888/actuator/health -UseBasicParsing
Invoke-WebRequest http://localhost:8888/auth-service/default -UseBasicParsingSet these in your terminal before starting services (or set as system environment variables):
$env:CLOUDINARY_CLOUD_NAME="<your-cloud-name>"
$env:CLOUDINARY_API_KEY="<your-api-key>"
$env:CLOUDINARY_API_SECRET="<your-api-secret>"Request registration OTP with profile image:
curl.exe -X POST "http://localhost:8080/api/auth/register/request-otp" `
-F "name=User One" `
-F "email=user1@example.com" `
-F "password=password123" `
-F "role=JOB_SEEKER" `
-F "phone=9999999999" `
-F "profileImage=@C:/path/to/profile.png"Apply for a job with resume upload:
curl.exe -X POST "http://localhost:8080/api/applications" `
-H "Authorization: Bearer <JOB_SEEKER_JWT>" `
-F "jobId=5" `
-F "resume=@C:/path/to/resume.pdf"Replace logged-in user's profile image:
curl.exe -X PUT "http://localhost:8080/api/auth/profile/image" `
-H "Authorization: Bearer <JWT>" `
-F "profileImage=@C:/path/to/new-profile.png"Replace a job seeker's own application resume:
curl.exe -X PUT "http://localhost:8080/api/applications/13/resume" `
-H "Authorization: Bearer <JOB_SEEKER_JWT>" `
-F "resume=@C:/path/to/new-resume.pdf"docker compose up -d mysql rabbitmq redis zipkin prometheus loki promtail grafana sonar-db sonarqubepowershell -ExecutionPolicy Bypass -File .\infrastructure\start-local.ps1Invoke-RestMethod "http://localhost:8080/api/jobs"
Invoke-RestMethod "http://localhost:8080/api/jobs/search?title=java&location=pune"Invoke-WebRequest "http://localhost:8080/actuator/prometheus" -UseBasicParsing
Invoke-WebRequest "http://localhost:9090/-/healthy" -UseBasicParsing
Invoke-WebRequest "http://localhost:3100/ready" -UseBasicParsing
Invoke-WebRequest "http://localhost:9411/zipkin/" -UseBasicParsing- Open Grafana:
http://localhost:3000 - Go to Explore
- Select datasource Loki
- Try these queries:
{job="docker"}
{service="api-gateway"}
{service="application-service"} |= "ERROR"
If you run services on localhost (not in Docker), Promtail also scrapes logs/*.log through the host-log-files job.
docker run -d --name loki -p 3100:3100 -v ${PWD}/infrastructure/loki/loki-config.yml:/etc/loki/loki-config.yml grafana/loki:3.1.1 -config.file=/etc/loki/loki-config.yml
docker run -d --name promtail -v ${PWD}/infrastructure/promtail/promtail-docker.yml:/etc/promtail/promtail.yml -v ${PWD}/logs:/host-logs:ro -v /var/run/docker.sock:/var/run/docker.sock:ro grafana/promtail:3.1.1 -config.file=/etc/promtail/promtail.yml# first login at http://localhost:9000 and create a token
$env:SONAR_HOST_URL="http://localhost:9000"
$env:SONAR_TOKEN="<YOUR_SONAR_TOKEN>"
.\gradlew sonarqube -Dsonar.token=$env:SONAR_TOKENdocker compose downdocker compose down -vUse powershell.exe commands/scripts (already used in infrastructure/start-local.ps1).
Restart in correct order (config-server first, discovery-server second), then restart all business services so they re-register in Eureka.
Check logs for first failure line, then verify:
- MySQL is running on
3306 - RabbitMQ is running on
5672 - Config Server is reachable on
8888 - Discovery Server is reachable on
8761
Quick port check:
netstat -ano | findstr ":8888 :8761 :8080 :3306 :5672"powershell -ExecutionPolicy Bypass -File .\infrastructure\stop-local.ps1
.\gradlew --stop
.\gradlew clean
powershell -ExecutionPolicy Bypass -File .\infrastructure\start-local.ps1- Open folder:
jobsportalgradle - Let IntelliJ import Gradle project
- Ensure project SDK = JDK 21
ConfigServerApplicationDiscoveryServerApplicationApiGatewayApplicationAuthServiceApplicationJobServiceApplicationApplicationServiceApplicationAdminServiceApplicationNotificationServiceApplication
Open: http://localhost:8761
You should see:
API-GATEWAYAUTH-SERVICEJOB-SERVICEAPPLICATION-SERVICEADMIN-SERVICENOTIFICATION-SERVICE
- Public endpoints:
POST /api/auth/register/request-otpPOST /api/auth/register/verify-otpPOST /api/auth/loginPOST /api/auth/password/forgot/request-otpPOST /api/auth/password/forgot/verify-otpPOST /api/auth/password/reset
- All other gateway routes require
Authorization: Bearer <token> - Gateway validates JWT and forwards:
X-User-IdX-User-EmailX-User-Role
- Direct
POST /api/auth/registeris intentionally disabled in strict mode and returns410 Gone.
For OTP registration, login, and forgot-password APIs, send only:
Content-Type: application/json
Do not send Authorization for these public auth endpoints.
For all other APIs (/api/jobs/**, /api/applications/**, /api/admin/**), send:
Content-Type: application/jsonAuthorization: Bearer <JWT_TOKEN_FROM_LOGIN>
Do not manually send X-User-Id, X-User-Email, X-User-Role; API Gateway adds them automatically after validating JWT.
curl --location 'http://localhost:8080/api/auth/register/request-otp' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "User One",
"email": "user1@example.com",
"username": "user1",
"password": "password123",
"role": "JOB_SEEKER",
"phone": "9999999999"
}'Check your email and verify OTP:
curl --location 'http://localhost:8080/api/auth/register/verify-otp' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user1@example.com",
"otp": "123456"
}'Direct register is disabled (expected 410 Gone):
curl --location 'http://localhost:8080/api/auth/register' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "User One",
"email": "user1@example.com",
"password": "password123"
}'curl --location 'http://localhost:8080/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user1@example.com",
"password": "password123"
}'Copy token from login response.
curl --location 'http://localhost:8080/api/jobs' \
--header 'Authorization: Bearer <TOKEN>'Request OTP:
curl --location 'http://localhost:8080/api/auth/password/forgot/request-otp' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user1@example.com"
}'Verify OTP (returns resetToken):
curl --location 'http://localhost:8080/api/auth/password/forgot/verify-otp' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user1@example.com",
"otp": "123456"
}'Reset password:
curl --location 'http://localhost:8080/api/auth/password/reset' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user1@example.com",
"resetToken": "<RESET_TOKEN_FROM_VERIFY_OTP>",
"newPassword": "newPass123"
}'Import this file in Postman:
postman/jobsportal-otp-auth.postman_collection.json
Recommended run order inside the collection:
1) Register - Request OTP- Set
registrationOtpfrom your email inbox 2) Register - Verify OTP3) Login(auto-savesauthToken)4) Forgot Password - Request OTP- Set
forgotOtpfrom your email inbox 5) Forgot Password - Verify OTP(auto-savesresetToken)6) Forgot Password - Reset Password7) Strict Check - Direct Register Disabled (410)
Notes:
- Collection variable
baseUrldefaults tohttp://localhost:8080 - OTP values are manual because they arrive by email
resetTokenis filled automatically from verify-forgot-otp response
POST /api/auth/register/request-otpPOST /api/auth/register/verify-otpPOST /api/auth/loginPOST /api/auth/password/forgot/request-otpPOST /api/auth/password/forgot/verify-otpPOST /api/auth/password/reset
POST /api/jobs(Recruiter only)GET /api/jobsGET /api/jobs/{id}GET /api/jobs/search?title=&location=
POST /api/applications(Job Seeker only)GET /api/applications/userGET /api/applications/job/{jobId}(Recruiter/Admin)PUT /api/applications/{id}/status?status=SHORTLISTED|REJECTED|UNDER_REVIEW|SELECTEDPUT /api/applications/job/{jobId}/user/{userId}/status?status=SHORTLISTED|REJECTED|UNDER_REVIEW|SELECTED(Recruiter only, preferred)
GET /api/admin/users(Admin only)GET /api/admin/jobs(Admin only)GET /api/admin/reports(Admin only)
-
Recruiter posts job
job-servicepublishesjob.postedevent to RabbitMQnotification-serviceconsumes event- It fetches all user emails from
auth-service - Sends email notification to all users
-
Recruiter marks applicant as SHORTLISTED or SELECTED
application-servicepublishesapplication.status.changednotification-servicesends email to that specific candidate
{
"name": "Recruiter One",
"email": "recruiter1@example.com",
"username": "recruiter1",
"password": "password123",
"role": "RECRUITER",
"phone": "9876543210"
}{
"email": "recruiter1@example.com",
"password": "password123"
}{
"title": "Java Backend Developer",
"companyName": "ABC Pvt Ltd",
"location": "Pune",
"salary": 1200000,
"experience": 3,
"description": "Spring Boot + Microservices"
}{
"jobId": 1,
"resumeUrl": "https://example.com/resume/user1.pdf"
}-
Gmail SMTP is configured using:
-
auth-serviceMySQL DB in config isauth_db. -
If your existing
userstable has a non-nullusernamecolumn, registration now supportsusernamein payload and auto-generates it from email when omitted. -
If you change config files in
config-server, restart all services in order so they reload updated config. -
For production, move secrets to environment variables or a secret manager.
-
If IntelliJ cannot build automatically and your system has no global Gradle installed, install Gradle or generate Gradle Wrapper from IntelliJ Gradle actions.
- Service YAML uses
spring.jpa.hibernate.ddl-auto: update, so schema evolves without dropping tables. auth-servicesupports legacy/non-nullusernamecolumn and auto-generates username from email if omitted.job-servicewrites recruiter identity to bothposted_byandrecruiter_idcompatible mappings.- Keep Config Server values as source of truth for DB URLs; avoid editing service-local YAML with conflicting datasource values.
- Start services in order (
config-server,discovery-server,api-gateway, then business services) - Register recruiter and seeker using OTP flow (
/api/auth/register/request-otp->/api/auth/register/verify-otp) - Login and copy JWT token (
/api/auth/login) - Recruiter posts job (
POST /api/jobswith Bearer token) - Seeker gets job list/search (
GET /api/jobs,GET /api/jobs/search) - Seeker applies (
POST /api/applications) - Recruiter updates status (
PUT /api/applications/{id}/status?status=SELECTED) - Notification service consumes events for job posting and selected/shortlisted updates
- Public auth APIs: only
Content-Type: application/json - Protected APIs:
Authorization: Bearer <token>(+Content-Type: application/jsonfor body requests) - Do not manually pass
X-User-Id,X-User-Email,X-User-Rolewhen using gateway
auth-service:AuthControllerTestjob-service:JobControllerTestapplication-service:ApplicationControllerTestadmin-service:AdminControllerTestnotification-service:NotificationListenerServiceTest
Run all tests from workspace root:
.\gradlew testRun module tests only:
.\gradlew :services:auth-service:test :services:job-service:test :services:application-service:test :services:admin-service:test :services:notification-service:testWorkflows added under .github/workflows:
ci.yml: runs on push/PR tomainanddevelop; executesclean testandbuildsonar.yml: runs Sonar analysis on push tomain/develop(and manual trigger)docker-cd.yml: builds and pushes all service images to GHCR on push tomainand version tags
SONAR_TOKEN: SonarQube token (required forsonar.yml)SONAR_HOST_URL: SonarQube server URL (for example:http://localhost:9000for self-hosted runner)
docker-cd.yml pushes images to:
ghcr.io/<github-owner>/jobsportal-config-serverghcr.io/<github-owner>/jobsportal-discovery-serverghcr.io/<github-owner>/jobsportal-api-gatewayghcr.io/<github-owner>/jobsportal-auth-serviceghcr.io/<github-owner>/jobsportal-job-serviceghcr.io/<github-owner>/jobsportal-application-serviceghcr.io/<github-owner>/jobsportal-admin-serviceghcr.io/<github-owner>/jobsportal-notification-service
Tags include commit SHA, latest (default branch), and Git tag refs.
- Open/update PR ->
ci.ymlvalidates code quality and build health - Merge to
main->ci.yml+sonar.yml+docker-cd.yml - Push tag like
v1.0.0->docker-cd.ymlpublishes versioned images - Manual run ->
sonar.ymlanddocker-cd.ymlsupportworkflow_dispatch
docker-cd.ymlusesinfrastructure/Dockerfile.serviceand builds each service via matrixSERVICE_MODULE- GHCR publish uses built-in
GITHUB_TOKENwithpackages: writepermission - For private SonarQube reachable only from local machine, use a self-hosted GitHub runner on that machine