- Overview
- Application Architecture
- Getting Started - Step by Step
- Configuration Guide
- How the Application Works
- Important Things to Keep in Mind
- Extending and Customizing
- Development Best Practices
- Troubleshooting
This is a production-ready Spring Boot 3 SAAS starter kit that provides a complete foundation for building multi-tenant or single-tenant SAAS applications. It includes user management, authentication, security, file uploads, rate limiting, caching, metrics, and observability features out of the box.
- Framework: Spring Boot 3.2.1
- Java Version: 17
- Build Tool: Maven
- Database: MySQL 8.0 (with Flyway migrations)
- Cache: Redis (optional, for caching and rate limiting)
- Security: Spring Security + JWT
- Documentation: OpenAPI 3 / Swagger
- Metrics: Micrometer + Prometheus
- File Storage: Local filesystem or AWS S3
- ✅ JWT Authentication with refresh tokens
- ✅ User registration and email verification
- ✅ Password reset functionality
- ✅ Account lockout protection
- ✅ Rate limiting (per-IP and per-user)
- ✅ File uploads (local or S3)
- ✅ Session management
- ✅ Audit logging
- ✅ CORS configuration
- ✅ API documentation (Swagger)
- ✅ Health checks and metrics
- ✅ Docker support
com.siyamuddin.blog.blogappapis/
├── Config/ # Configuration classes
│ ├── Properties/ # Configuration property classes
│ ├── SecurityConfig # Spring Security configuration
│ ├── CacheConfig # Redis caching configuration
│ ├── MetricsConfig # Micrometer metrics
│ └── ...
├── Controllers/ # REST API endpoints
│ ├── AuthController # Authentication endpoints
│ └── UserController # User management endpoints
├── Entity/ # JPA entities (database models)
│ ├── User # User entity
│ ├── Role # Role entity
│ ├── RefreshToken # Refresh token entity
│ └── ...
├── Repository/ # Data access layer (Spring Data JPA)
├── Services/ # Business logic layer
│ ├── Impl/ # Service implementations
│ └── Storage/ # File storage abstractions
├── Security/ # Security-related classes
│ ├── JwtHelper # JWT token generation/validation
│ ├── JwtAuthenticationFilter # JWT filter
│ └── RateLimitInterceptor # Rate limiting
├── Exceptions/ # Custom exceptions and global handler
└── Payloads/ # DTOs and request/response models
- Request →
RateLimitInterceptor(checks rate limits) - →
JwtAuthenticationFilter(validates JWT if authenticated) - →
Controller(handles HTTP request) - →
Service(business logic) - →
Repository(database operations) - → Response (with proper status codes and error handling)
The application uses Flyway for database migrations:
V1__init_roles.sql- Creates role table and seeds admin/normal rolesV2__add_profile_photo_columns.sql- Adds profile image columns
Key tables:
user- User accounts with email, password, profile inforole- User roles (ROLE_ADMIN, ROLE_NORMAL)user_role- Many-to-many relationshiprefresh_token- Refresh tokens for JWT rotationuser_session- Active user sessionstoken_blacklist- Invalidated tokensaudit_log- Security and user action audit trail
- Java 17 or higher
- Maven 3.6+ (or use included
mvnw) - MySQL 8.0+ (or use Docker Compose)
- Redis (optional, for caching and rate limiting)
- Docker & Docker Compose (optional, for easy setup)
# Clone the repository
git clone <repository-url>
cd saas-starter
# Explore the structure
ls -laIMPORTANT: The application requires a JWT_SECRET environment variable.
# Copy the example environment file
cp env.example .env
# Edit .env and set your values
# CRITICAL: Set JWT_SECRET to a random string at least 32 characters long
nano .envRequired Environment Variables:
JWT_SECRET- REQUIRED - Must be at least 32 characters (used for JWT signing)SPRING_DATASOURCE_URL- Database connection URLSPRING_DATASOURCE_USERNAME- Database usernameSPRING_DATASOURCE_PASSWORD- Database password
Optional but Recommended:
REDIS_HOST- Redis host (default: localhost)REDIS_PORT- Redis port (default: 6379)APP_EMAIL_FROM- Email sender addressAPP_CORS_ALLOWED_ORIGINS- Comma-separated list of allowed CORS origins
# Start MySQL and Redis
docker compose up -d db redis
# Wait for MySQL to be healthy (check logs)
docker compose logs -f db
# Database will be created automatically by Flyway migrations# Create database manually
mysql -u root -p
CREATE DATABASE saas_app;
exit
# Update .env or application-dev.properties with your connection detailsThe application uses Spring profiles:
dev- Development profile (default)prod- Production profile
Key configuration files:
application.properties- Base configuration (shared)application-dev.properties- Development overridesapplication-prod.properties- Production overrides
Development defaults (application-dev.properties):
- Database:
jdbc:mysql://localhost:3306/saas_app - Hibernate DDL:
update(auto-updates schema) - Debug logging enabled
Production (set via environment variables):
- Hibernate DDL: Should be
validateornone - Proper JWT secret (32+ characters)
- Real SMTP configuration
- CORS origins configured
# Clean and build
./mvnw clean install
# Or on Windows
mvnw.cmd clean install# Run with default profile (dev)
./mvnw spring-boot:run
# Or run with specific profile
./mvnw spring-boot:run -Dspring-boot.run.profiles=prod# Start everything (MySQL, Redis, Application)
docker compose up --build
# View logs
docker compose logs -f app
# Stop everything
docker compose down# Build JAR
./mvnw clean package
# Run JAR
java -jar target/saas-starter-0.0.1-SNAPSHOT.jar
# Or with profile
java -jar -Dspring.profiles.active=prod target/saas-starter-0.0.1-SNAPSHOT.jar-
Check health endpoint:
curl http://localhost:9090/actuator/health
-
Access Swagger UI: Open browser:
http://localhost:9090/swagger-ui/index.html -
Check application logs for:
- Database connection success
- Flyway migrations applied
- Environment validation passed
- No critical errors
Use Swagger UI or curl:
# Register a new user
curl -X POST http://localhost:9090/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "SecurePass123!",
"about": "Test user"
}'Note: In development, check logs for email verification token (if SMTP not configured, emails won't send but tokens are generated).
Why it matters: Used to sign and verify JWT tokens. If compromised, attackers can forge tokens.
# Generate a secure secret (32+ characters)
openssl rand -base64 32
# Set in environment
export JWT_SECRET="your-generated-secret-here"Or in .env file:
APP_JWT_SECRET=your-generated-secret-hereDevelopment:
# application-dev.properties
spring.datasource.url=jdbc:mysql://localhost:3306/saas_app?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=updateProduction (via environment variables):
SPRING_DATASOURCE_URL=jdbc:mysql://your-db-host:3306/saas_app
SPRING_DATASOURCE_USERNAME=dbuser
SPRING_DATASOURCE_PASSWORD=secure-passwordIMPORTANT: In production, set spring.jpa.hibernate.ddl-auto=validate or none and rely only on Flyway migrations.
If caching is enabled:
# application.properties
app.caching.enabled=true
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password= # OptionalDisable caching (no Redis needed):
app.caching.enabled=falseDevelopment (MailHog or similar):
spring.mail.host=localhost
spring.mail.port=1025
spring.mail.username=
spring.mail.password=
spring.mail.properties.mail.smtp.auth=falseProduction (SMTP provider):
SPRING_MAIL_HOST=smtp.gmail.com
SPRING_MAIL_PORT=587
SPRING_MAIL_USERNAME=your-email@gmail.com
SPRING_MAIL_PASSWORD=your-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_TLS=trueDevelopment:
APP_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080Production:
APP_CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.comLocal Storage (Default):
filestorage.mode=local
filestorage.local.base-path=uploads
filestorage.local.public-uri-prefix=/uploadsS3 Storage:
filestorage.mode=s3
filestorage.s3.bucket-name=your-bucket-name
filestorage.s3.region=us-east-1
filestorage.s3.access-key=${AWS_ACCESS_KEY_ID}
filestorage.s3.secret-key=${AWS_SECRET_ACCESS_KEY}
filestorage.s3.public-base-url=https://cdn.yourdomain.com| Property | Description | Default | Required |
|---|---|---|---|
JWT_SECRET |
Secret for JWT signing | - | ✅ Yes |
app.jwt.access-token-validity |
Access token expiry (seconds) | 900 (15 min) | No |
app.jwt.refresh-token-validity |
Refresh token expiry (seconds) | 604800 (7 days) | No |
app.security.max-failed-login-attempts |
Failed attempts before lockout | 5 | No |
app.security.account-lockout-duration-minutes |
Lockout duration | 30 | No |
app.rate-limit.login.requests |
Login requests per duration | 10 | No |
app.rate-limit.login.duration |
Duration in hours | 1 | No |
app.caching.enabled |
Enable Redis caching | true | No |
server.port |
Application port | 9090 | No |
See application.properties for complete list.
-
Registration:
POST /api/v1/auth/register → UserService.registerNewUser() → EmailVerificationService sends verification email → User created with emailVerified=false -
Email Verification:
POST /api/v1/auth/verify-email?token=xxx → EmailVerificationService.verifyEmail() → User.emailVerified = true -
Login:
POST /api/v1/auth/login → RateLimitInterceptor checks per-IP limit → AuthenticationManager validates credentials → AccountSecurityService checks if locked → JwtHelper generates access + refresh tokens → SessionService creates session → Returns JWT tokens -
Accessing Protected Endpoints:
GET /api/v1/users/me → JwtAuthenticationFilter extracts token from Authorization header → JwtHelper validates token → Sets SecurityContext with UserDetails → Controller processes request -
Token Refresh:
POST /api/v1/auth/refresh-token?refreshToken=xxx → Validates refresh token from database → Generates new access token → Returns new access token (same refresh token) -
Logout:
POST /api/v1/auth/logout → TokenBlacklistService blacklists access token → SessionService invalidates all sessions → RefreshTokenRepo revokes all refresh tokens
How it works:
- Uses Bucket4j library for token bucket algorithm
- Stores buckets in Redis (distributed) or in-memory (single instance)
- Applied via
RateLimitInterceptorbefore controller execution
Rate limit types:
- Per-IP (unauthenticated): Login, registration, password reset
- Per-User (authenticated): Post creation, comments, password change
- General API: All authenticated endpoints (default: 50,000/hour)
Configuration:
app.rate-limit.login.requests=10
app.rate-limit.login.duration=1 # hoursResponse headers when rate limited:
X-RateLimit-Limit: Maximum requests allowedX-RateLimit-Remaining: Remaining requestsX-RateLimit-Reset: Time until reset (seconds)
Storage Modes:
-
Local Mode (
filestorage.mode=local):- Files stored in
uploads/directory - Served via
/uploads/**endpoint - Configurable base path
- Files stored in
-
S3 Mode (
filestorage.mode=s3):- Files uploaded to AWS S3
- Returns public URL or CDN URL
- Supports cleanup of old files
Usage Example:
// Profile photo upload automatically uses configured storage
POST /api/v1/users/me/profile-photo
Content-Type: multipart/form-data
file: [binary]Storage abstraction:
FileStorageServiceinterfaceLocalFileStorageServiceimplementationS3FileStorageServiceimplementation- Easy to add new backends (Azure, GCS, etc.)
When enabled:
- Redis must be running
app.caching.enabled=true
Cached entities:
- User lookups (to reduce database queries)
- Configurable TTL per cache
Adding cache:
@Cacheable(value = "users", key = "#id")
public UserDto getUserById(Integer id) {
// ...
}Available metrics:
-
Business Metrics (custom):
app.auth.login.attempts- Login attempts (total/success/failure)app.auth.registrations- User registrationsapp.auth.password.reset.*- Password reset eventsapp.sessions.active- Current active sessionsapp.accounts.locked- Currently locked accounts
-
HTTP Metrics (automatic):
- Request counts, durations, errors
- Available at
/actuator/metrics
-
Prometheus Endpoint:
/actuator/prometheus- Exports metrics in Prometheus format
Viewing metrics:
# Prometheus format
curl http://localhost:9090/actuator/prometheus
# JSON format
curl http://localhost:9090/actuator/metrics-
Account Lockout:
- After N failed login attempts, account locks
- Configurable duration (default: 30 minutes)
- Automatic unlock after duration expires
-
Password Validation:
- Minimum length (default: 8)
- Requires uppercase, lowercase, digit, special character
- Configurable via
app.security.password-*properties
-
Token Blacklisting:
- Logged-out tokens stored in blacklist
- Prevents reuse of old tokens
- Cleaned up via scheduled task
-
Session Management:
- Track active sessions per user
- Revoke sessions individually or all at once
- Session timeout configurable
-
JWT_SECRET:
- MUST be at least 32 characters
- MUST be kept secret (never commit to git)
- MUST be different in dev/prod environments
- Generate using:
openssl rand -base64 32
-
Database Passwords:
- Never hardcode in source code
- Use environment variables or secrets management
- Use strong passwords in production
-
Email Verification:
- Configure
app.security.require-email-verification-for-login=truein production - Ensure SMTP is properly configured
- Test email delivery before going live
- Configure
-
CORS Configuration:
- CRITICAL in production: Set
APP_CORS_ALLOWED_ORIGINSto your actual frontend domains - Don't use wildcards (
*) in production - Test CORS with actual frontend application
- CRITICAL in production: Set
-
Rate Limiting:
- Default limits are per-application-instance (if using in-memory)
- For distributed systems, ensure Redis is configured
- Adjust limits based on your use case
- JWT_SECRET set and secure (32+ chars)
- Database credentials secure and not in code
- Hibernate DDL set to
validateornone(rely on Flyway) - SMTP configured for email delivery
- CORS origins configured for your frontend
- File storage configured (S3 recommended for production)
- Redis configured if using caching/rate limiting
- Logging configured appropriately (not DEBUG in prod)
- Health checks monitored
- Backup strategy for database
- SSL/HTTPS configured (recommended)
-
Use Environment Variables:
- Never hardcode secrets
- Use
.envfile (add to.gitignore) - Document required variables in
env.example
-
Database Migrations:
- Always use Flyway migrations for schema changes
- Never modify existing migrations (create new ones)
- Test migrations on a copy of production data
-
Testing:
- Run tests before committing:
./mvnw test - Write tests for new features
- Test security-critical paths manually
- Run tests before committing:
-
Code Organization:
- Keep controllers thin (delegate to services)
- Use DTOs for request/response (don't expose entities)
- Follow package structure conventions
-
Error Handling:
- Use
GlobalExceptionHandlerfor consistent error responses - Include error codes from
ErrorCodeenum - Log errors appropriately (not stack traces in response)
- Use
-
Forgetting JWT_SECRET:
- Application will fail to start
- Error in logs: "JWT_SECRET environment variable is required"
-
Database Connection Issues:
- Check MySQL is running
- Verify connection URL, username, password
- Check network connectivity
-
Redis Connection Issues:
- If caching enabled but Redis down, application may fail
- Set
app.caching.enabled=falseto disable temporarily
-
Email Not Sending:
- Check SMTP configuration
- Verify
app.email.enabled=true - Check application logs for SMTP errors
-
CORS Errors:
- Browser shows CORS errors if origins not configured
- Check
APP_CORS_ALLOWED_ORIGINSenvironment variable - Verify frontend URL matches configured origins
-
File Upload Issues:
- Check file size limits (
spring.servlet.multipart.max-file-size) - Verify storage path exists and is writable (local mode)
- Check S3 credentials and bucket permissions (S3 mode)
- Check file size limits (
- Create Entity Class:
@Entity
@Table(name = "your_table")
@Getter
@Setter
public class YourEntity extends BaseEntity {
// fields with JPA annotations
}- Create Repository:
@Repository
public interface YourEntityRepository extends JpaRepository<YourEntity, Integer> {
// custom query methods
}- Create DTO:
public class YourEntityDto {
// fields matching your needs
}- Create Service:
@Service
public class YourEntityService {
// business logic
}- Create Controller:
@RestController
@RequestMapping("/api/v1/your-entities")
public class YourEntityController {
// endpoints
}- Add Migration:
- Create
V3__create_your_table.sqlinsrc/main/resources/db/migration/
- Create
- Add method to appropriate Controller
- Use
@PreAuthorizefor authorization if needed - Add rate limiting if required (via
RateLimitInterceptor) - Document with OpenAPI annotations (
@Operation,@ApiResponses) - Test via Swagger UI
- Add property to
RateLimitProperties:
private RateLimitConfig yourFeature = new RateLimitConfig(10, 1);- Add method to
RateLimitService:
public boolean tryConsumeYourFeature(String identifier) {
return getBucket("rate-limit:your-feature:" + identifier,
rateLimitProperties.getYourFeature())
.tryConsume(1);
}- Add check in
RateLimitInterceptor:
if (isYourFeatureEndpoint(requestURI, method)) {
ConsumptionProbe probe = rateLimitService.tryConsumeAndReturnRemainingYourFeature(identifier);
// handle rate limit
}- Implement
FileStorageServiceinterface:
@Service
@ConditionalOnProperty(name = "filestorage.mode", havingValue = "your-backend")
public class YourStorageService implements FileStorageService {
// implement store() and delete() methods
}-
Update
FileStorageProperties.StorageModeenum if needed -
Configure properties for your backend
Changing Password Requirements:
- Modify
app.security.password-*properties - Update
PasswordValidationServiceif needed
Changing Lockout Policy:
- Modify
app.security.max-failed-login-attempts - Modify
app.security.account-lockout-duration-minutes
Adding Custom Roles:
- Create Flyway migration to insert new roles
- Update
RolePropertiesif needed
- Create service interface and implementation in
Services/andServices/Impl/ - Inject dependencies via constructor
- Use repositories for data access
- Add logging for important operations
- Add metrics if it's a key business metric
- Add audit logging for security-sensitive operations
-
Use Lombok (already included):
@Getter,@Setterfor entities@RequiredArgsConstructorfor constructors@Slf4jfor logging
-
Follow Spring Boot Conventions:
- Controllers return
ResponseEntity<T> - Use
@Validfor request validation - Use DTOs, not entities, in API responses
- Controllers return
-
Error Handling:
- Use custom exceptions from
Exceptions/package - Let
GlobalExceptionHandlerhandle mapping to HTTP responses - Include meaningful error messages
- Use custom exceptions from
Run tests:
./mvnw testWrite unit tests:
- Service layer: Mock repositories
- Controller layer: Mock services
- Security: Test authentication/authorization
Integration tests:
- Test complete flows (register → verify → login)
- Use
@SpringBootTestannotation
Log levels:
DEBUG: Detailed information for debugging (dev only)INFO: General application flowWARN: Potential issuesERROR: Error conditions
Security events:
- Use
SecurityEventLoggerfor security-related logs - Use
AuditServicefor audit trail
-
Never commit:
.envfiles- Secrets or passwords
- Build artifacts (
target/) - IDE files (unless team-wide)
-
Always commit:
- Database migrations
- Configuration examples (
env.example) - Documentation updates
Problem: Environment validation fails
Error: JWT_SECRET environment variable is required
Solution:
- Set
JWT_SECRETenvironment variable (32+ characters) - Check
.envfile is in project root - Verify
EnvironmentValidatorlogs for specific issues
Problem: Database connection fails
Error: Unable to connect to database
Solution:
- Verify MySQL is running:
docker compose psormysql -u root -p - Check connection URL, username, password
- Ensure database exists (or
createDatabaseIfNotExist=trueis set) - Check firewall/network connectivity
Problem: Rate limits not enforced
Solution:
- Verify
RateLimitInterceptoris registered (checkWebConfig) - Check Redis is running if using distributed rate limiting
- Review application logs for rate limit messages
- Verify endpoint is not excluded from interceptor
Problem: No verification emails received
Solution:
- Check
app.email.enabled=true - Verify SMTP configuration (
spring.mail.*properties) - Check application logs for SMTP errors
- Test SMTP connection separately
- In development, check MailHog or similar tool
Problem: Access-Control-Allow-Origin error
Solution:
- Set
APP_CORS_ALLOWED_ORIGINSenvironment variable - Include your frontend URL (e.g.,
http://localhost:3000) - Restart application after changing CORS config
- Check browser console for exact error
Problem: File upload returns error
Solution:
- Check file size doesn't exceed
spring.servlet.multipart.max-file-size - Verify storage directory exists and is writable (local mode)
- Check S3 credentials and bucket permissions (S3 mode)
- Review application logs for specific error
Problem: Slow response times
Solution:
- Enable caching (
app.caching.enabled=true) and ensure Redis is running - Check database query performance (enable SQL logging in dev)
- Review rate limiting impact (too restrictive?)
- Check connection pool settings (
spring.datasource.hikari.*)
README.md- Overview and quick start (this file)
- Spring Boot Documentation
- Spring Security Reference
- JWT.io - JWT token decoder/debugger
- Flyway Documentation
- Check application logs first
- Review this guideline and GUIDELINE.md
- Check Swagger UI for API documentation
- Review test files for usage examples
- Check GitHub issues (if applicable)
# Build
./mvnw clean install
# Run
./mvnw spring-boot:run
# Test
./mvnw test
# Docker Compose
docker compose up -d # Start services
docker compose logs -f app # View logs
docker compose down # Stop services
# Database
docker compose exec db mysql -u root -p- Swagger UI:
http://localhost:9090/swagger-ui/index.html - Health:
http://localhost:9090/actuator/health - Metrics:
http://localhost:9090/actuator/metrics - Prometheus:
http://localhost:9090/actuator/prometheus - API Base:
http://localhost:9090/api/v1/
- Config:
src/main/resources/application*.properties - Migrations:
src/main/resources/db/migration/ - Environment:
.env(create fromenv.example) - Logs: Check console output or Docker logs
Last Updated: 2025/11 Maintained By: Development UDDIN SIYAM
For questions or improvements to this guide, please open an issue or submit a pull request.