Production-ready template with JWT auth, rate limiting (Bucket4j), Redis/Lettuce, Actuator, CORS, and Testcontainers.
- Java 21
- Docker (for Testcontainers and local dev)
Use environment variables (recommended via a .env file when running locally). See the list below and .env.example.
JWT_SECRET: Base64 encoded secret (HS512). Must decode to at least 64 bytes.APP_JWT_VALIDITY_SECONDS: Access token TTL (seconds).APP_JWT_REMEMBER_VALIDITY_SECONDS: Access token TTL when remember-me (seconds).APP_JWT_ISSUER: JWTissclaim.APP_JWT_AUDIENCE: JWTaudclaim.APP_JWT_CLOCK_SKEW_SECONDS: Allowed clock skew for parsing (seconds).APP_JWT_REFRESH_TTL_DAYS: Refresh token TTL (days).
APP_PASSWORD_REQUIRE_SPECIAL:true|false. Whentrue, passwords must include a non-alphanumeric (special) character in addition to length, lower/upper and digit checks. Whenfalse(default), only length + lower/upper + digit are required.
APP_CORS_ALLOWED_ORIGINS: Allowed origins (e.g.*orhttps://example.com).
APP_RATE_LIMIT_ENABLED:true|false.APP_RATE_LIMIT_BACKEND:none|redis.APP_RATE_LIMIT_AUTH_MAX_REQUESTS,APP_RATE_LIMIT_AUTH_WINDOW_SECONDS: Limits for/api/authenticate.APP_RATE_LIMIT_REFRESH_MAX_REQUESTS,APP_RATE_LIMIT_REFRESH_WINDOW_SECONDS: Limits for/api/refresh.APP_RATE_LIMIT_IN_MEMORY_RETENTION_SECONDS: TTL for in-memory counters cleanup whenAPP_RATE_LIMIT_BACKEND=none. Prevents unbounded growth of IP keys when Redis is not used. Default900. Ignored when using Redis.
REDIS_HOST,REDIS_PORT,REDIS_SSL.
SPRING_DATASOURCE_URL,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD.
./gradlew bootRunThe build task auto-loads .env for bootRun only.
./gradlew testTests default to APP_RATE_LIMIT_BACKEND=none. Tests that need Redis or DB start Testcontainers and set properties dynamically.
Build jar and image:
./gradlew bootJar
docker build -t mohirdev/jwt-app:latest .Run container (supply env vars):
docker run --rm -p 8085:8085 --env-file .env mohirdev/jwt-app:latestCompose reads variables from .env by default.
# Build jar and image
./gradlew bootJar
# Start stack with .env
docker compose up --build -d
# Tail logs
docker compose logs -f appNotes:
- Postgres:
postgres:17-alpine(latest major) - Redis:
redis:7-alpine - App: built from local Dockerfile, exposes
8085
SERVER_PORT=8085
SPRING_DATASOURCE_USERNAME=mohirdev
SPRING_DATASOURCE_PASSWORD=mohirdev
JWT_SECRET=PASTE_BASE64_64B_PLUS
APP_JWT_VALIDITY_SECONDS=3600
APP_JWT_REMEMBER_VALIDITY_SECONDS=86400
APP_JWT_ISSUER=mohirdev-auth
APP_JWT_AUDIENCE=mohirdev-api
APP_JWT_CLOCK_SKEW_SECONDS=30
APP_JWT_REFRESH_TTL_DAYS=14
APP_CORS_ALLOWED_ORIGINS=*
APP_RATE_LIMIT_ENABLED=true
APP_RATE_LIMIT_BACKEND=redis
APP_RATE_LIMIT_AUTH_MAX_REQUESTS=20
APP_RATE_LIMIT_AUTH_WINDOW_SECONDS=10
APP_RATE_LIMIT_REFRESH_MAX_REQUESTS=30
APP_RATE_LIMIT_REFRESH_WINDOW_SECONDS=10
APP_RATE_LIMIT_IN_MEMORY_RETENTION_SECONDS=900
# Password policy
APP_PASSWORD_REQUIRE_SPECIAL=false
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_SSL=falsePublic/auth endpoints:
POST /api/authenticate— authenticate with username/password. Returns access token and refresh token.POST /api/refresh— rotate refresh token and return new access token (+new refresh token).POST /api/logout— revoke a refresh token.
User endpoints:
POST /api/register— register a new user (public). AssignsROLE_USER.GET /api/users— list users (requiresROLE_ADMIN).
Actuator health (no auth required):
GET /actuator/healthGET /actuator/health/readiness— includesredisanddbcontributors; returns 503 if any is DOWN.
Swagger UI:
/swagger-ui.htmland/swagger-ui/index.html- OpenAPI JSON:
/v3/api-docs
When running the app container but using your locally installed Postgres, containers cannot use localhost to reach the host. Use host.docker.internal (Docker Desktop Windows/Mac).
Example .env for this mode:
SERVER_PORT=8085
SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/mohirdev_database
SPRING_DATASOURCE_USERNAME=YOUR_DB_USER
SPRING_DATASOURCE_PASSWORD=YOUR_DB_PASSWORD
# JWT (required; HS512 base64, decode >= 64 bytes)
JWT_SECRET=PASTE_BASE64_64B_PLUS
APP_JWT_VALIDITY_SECONDS=3600
APP_JWT_REMEMBER_VALIDITY_SECONDS=86400
APP_JWT_ISSUER=mohirdev-auth
APP_JWT_AUDIENCE=mohirdev-api
APP_JWT_CLOCK_SKEW_SECONDS=30
APP_JWT_REFRESH_TTL_DAYS=14
# CORS
APP_CORS_ALLOWED_ORIGINS=*
# Rate limit
APP_RATE_LIMIT_ENABLED=true
# If you do not run Redis locally, keep 'none'
APP_RATE_LIMIT_BACKEND=none
# If you run Redis locally, switch to 'redis' and set host:
# APP_RATE_LIMIT_BACKEND=redis
# REDIS_HOST=host.docker.internal
# REDIS_PORT=6379
# REDIS_SSL=false
APP_RATE_LIMIT_AUTH_MAX_REQUESTS=20
APP_RATE_LIMIT_AUTH_WINDOW_SECONDS=10
APP_RATE_LIMIT_REFRESH_MAX_REQUESTS=30
APP_RATE_LIMIT_REFRESH_WINDOW_SECONDS=10
# In-memory counter cleanup TTL (seconds), only used when backend=none
APP_RATE_LIMIT_IN_MEMORY_RETENTION_SECONDS=900
# Password policy: require a special (non-alphanumeric) character
APP_PASSWORD_REQUIRE_SPECIAL=falseRun:
docker run --rm -p 8085:8085 --env-file .env mohirdev/jwt-app:latestCopy as a starting point for local development. Adjust values as needed.
# Server
SERVER_PORT=8085
# Database
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/mohirdev_database
SPRING_DATASOURCE_USERNAME=postgres
SPRING_DATASOURCE_PASSWORD=postgres
# JWT (HS512, base64; must decode >= 64 bytes)
JWT_SECRET=REPLACE_WITH_BASE64_64B_PLUS
APP_JWT_VALIDITY_SECONDS=3600
APP_JWT_REMEMBER_VALIDITY_SECONDS=86400
APP_JWT_ISSUER=mohirdev-auth
APP_JWT_AUDIENCE=mohirdev-api
APP_JWT_CLOCK_SKEW_SECONDS=30
APP_JWT_REFRESH_TTL_DAYS=14
# CORS
APP_CORS_ALLOWED_ORIGINS=*
# Rate limiting
APP_RATE_LIMIT_ENABLED=true
# Use 'none' to keep everything in-memory (no Redis required)
APP_RATE_LIMIT_BACKEND=none
APP_RATE_LIMIT_AUTH_MAX_REQUESTS=20
APP_RATE_LIMIT_AUTH_WINDOW_SECONDS=10
APP_RATE_LIMIT_REFRESH_MAX_REQUESTS=30
APP_RATE_LIMIT_REFRESH_WINDOW_SECONDS=10
# Redis (only needed when APP_RATE_LIMIT_BACKEND=redis)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_SSL=false
# In-memory rate limit cleanup (seconds), used only when backend=none
APP_RATE_LIMIT_IN_MEMORY_RETENTION_SECONDS=900
# Password policy: require a special character in addition to lower/upper/digit
APP_PASSWORD_REQUIRE_SPECIAL=false