English | Português
Spring Boot backend template: Java 17, Maven, PostgreSQL + Flyway + Spring Data JPA, JWT auth via Spring Security, and a reference Note CRUD domain to copy for new resources. Sibling to back-template-laravel and back-template-nest.
- JWT auth with short-lived access tokens and rotating, revocable refresh tokens (refresh tokens are hashed and persisted so they can actually be revoked, not just left to expire)
- Email verification and password reset flows, with a console fallback for email in dev so there's nothing to configure to test locally
- Role-based access control (
USER/ADMIN) enforced with@PreAuthorize, plus a reference admin-only route - Rate limiting on
/auth/loginand/auth/register(Bucket4j, in-memory token bucket per IP) - Flyway-managed schema with
hibernate.ddl-auto=validate, so nothing drifts silently - OpenAPI docs and Swagger UI generated from controller annotations
- Dockerized app and Postgres via docker-compose, with health checks on both
- CI on GitHub Actions: format check, unit tests, Docker build, Testcontainers integration tests
- Reference
NoteCRUD domain to model new resources on
- Java 17, Spring Boot 3.5 (Web, Data JPA, Security, Validation, Actuator, Mail)
- PostgreSQL, Flyway migrations
- JWT via
jjwt0.12.6 - Bucket4j for rate limiting
- springdoc-openapi for API docs and Swagger UI
- Lombok
- JUnit 5 + Testcontainers for integration tests
- Spotless (Google Java Format) for formatting
- Docker / docker-compose
- GitHub Actions CI
src/main/java/com/example/backtemplate/
├── account/ change password, delete account
├── admin/ admin-only endpoints (list users)
├── auth/ register, login, refresh, JWT, users, roles
├── common/ base entity, API exceptions, global error handling
├── config/ security chain, JWT filter, rate limiting, OpenAPI, typed app properties
├── email/ SMTP service + console fallback for dev
├── notes/ reference CRUD domain
└── BackTemplateApplication.java
src/main/resources/
├── application.yml, application-{dev,prod,test}.yml
└── db/migration/ Flyway SQL migrations
Java 17, Maven (or the bundled ./mvnw), and Docker (for Postgres, Testcontainers, and the full containerized stack).
cp .env.example .env
make docker-up # starts Postgres
make db-migrate
make dev # runs the app on :8080 (dev profile)Or run the full containerized stack (app + db):
docker compose up -d --build| Command | Does |
|---|---|
make dev |
run with the dev profile (spring-boot:run) |
make build |
package the jar |
make start |
run the packaged jar |
make lint / make format-check |
Spotless check |
make format |
Spotless apply |
make test |
unit tests only |
make test-e2e |
integration tests (Testcontainers, needs Docker) |
make db-migrate |
apply Flyway migrations |
make db-generate |
scaffold a new empty timestamped migration file |
make docker-up / make docker-down |
full stack via compose |
POST /auth/register,GET /auth/verify-email?token=,POST /auth/login,POST /auth/refreshPOST /auth/forgot-password,POST /auth/reset-passwordPATCH /account/password,DELETE /account(Bearer auth required)GET/POST/PUT/DELETE /api/notes[/{id}](Bearer auth required) — reference CRUDGET /admin/users(admin only) — reference for protecting an admin-only routeGET /actuator/health
Every user has a role (USER or ADMIN, default USER), carried as a signed claim in the JWT (JwtService.generateAccessToken) and turned into a ROLE_* GrantedAuthority by JwtAuthFilter. Never trust a role value coming from a request body. GET /admin/users is the reference for protecting a route with @PreAuthorize("hasRole('ADMIN')") (needs @EnableMethodSecurity on SecurityConfig, already added). There's no self-serve way to become admin: flip the column directly for local testing (UPDATE users SET role = 'ADMIN' WHERE email = '...').
login and refresh return { accessToken, refreshToken }, both JWTs (JwtService). The refresh token also gets a SHA-256 hash of itself persisted in the refresh_tokens table so it can actually be revoked; a bare stateless JWT can't be un-issued before it expires. Access-token validation on every other request stays fully stateless. The DB lookup only happens on the /auth/refresh and /auth/logout paths.
refresh rotates: the old row is deleted the moment a new pair is issued, so a stolen-and-replayed refresh token stops working right after the legitimate client's next refresh. logout revokes a refresh token outright and is idempotent (a missing or already-revoked token still returns 200). The refresh JWT also carries a random jti claim. Without one, two tokens minted for the same user in the same second would be byte-identical (JWT claims are second-precision), which would silently defeat rotation.
OpenAPI docs are generated from Bean Validation annotations and springdoc-openapi's @Tag/@Operation/@SecurityRequirement annotations on controllers. With the app running, open the Swagger UI at /swagger-ui/index.html (raw spec at /v3/api-docs): http://localhost:8080 if you started it with make dev, or http://localhost:8081 if you're running the docker-compose stack (the default host port from .env.example, override with PORT). Use the "Authorize" button with a JWT from /auth/login to try protected routes (account, api/notes).
git config core.hooksPath .githooksRuns spotless:check before every commit.
See .env.example for the full list. JWT_SECRET is required (min 32 chars); the app fails fast on boot if it's missing or too short. Leave MAIL_HOST unset in dev to use the console fallback for transactional email: verification and reset tokens get logged instead of actually sent.
hibernate.ddl-autoisvalidateeverywhere; Flyway owns the schema. Never switch it toupdateorcreate, even locally, since it silently masks migrations you forgot to write.@ConfigurationPropertiesbinding path must match the YAML nesting exactly. A flat Java field (e.g.jwtSecret) only binds to a flat property (app.jwt-secret), never to a nested YAML path likeapp.jwt.secret; that needs a real nested class (seeAppProperties.Jwt). This bit us in practice: the mismatch silently left the fieldnullno matter how the value was supplied (env var, system property, whatever), and a weak unit test masked it because anullfield also fails@NotBlankvalidation, so the "fail fast" test passed for the wrong reason. If you add a new nested config group, mirror the YAML shape in a nested class instead of flattening it.- Also avoid
@Configurationplus@EnableConfigurationProperties(X.class)on the same classX: it double-registers the bean (one instance goes through proper binding, the other is a bare CGLIB-proxied instance with unbound fields), and whichever one autowiring picks up first may be the wrong one. Use plain@ConfigurationPropertieswith@ConfigurationPropertiesScanon the main application class instead. ConditionalOnPropertycannot express "empty vs. unset" cleanly. An env-resolved empty string (${MAIL_HOST:}with nothing set) still counts as "present" for a bare@ConditionalOnProperty(name = "host")check, so bothConsoleEmailServiceandSmtpEmailServiceactivated at once. Fixed with explicitConditionclasses keyed onStringUtils.hasText.- Actuator auto-includes a mail health indicator once
spring-boot-starter-mailis on the classpath. It tries to reach the configured (or defaultlocalhost:587) SMTP host and fails whenever none is configured, which is exactly the dev/console-fallback scenario this template is built around. Disabled viamanagement.health.mail.enabled=false. - The
maven:3.9.9-eclipse-temurin-17Docker build-stage image setsMAVEN_CONFIG=/root/.m2; the Maven Wrapper script injects that value as the first CLI argument to Maven, which then tries to parse it as a lifecycle phase and fails ("Unknown lifecycle phase /root/.m2"). Cleared viaENV MAVEN_CONFIG=""in the Dockerfile. docker-compose.yml'senv_file: .envinjects.env'sPORT(meant as the host-side port for theports:mapping) into the container too, which makes Spring bind to that port internally. That desyncs fromEXPOSE 8080and theHEALTHCHECK, leaving the container stuck at "health: starting" forever. Fixed by explicitly overridingPORT=8080in theappservice'senvironment:block, which takes precedence overenv_file:for the same key.- Testcontainers needs a Docker daemon reachable from wherever tests run. GitHub-hosted Actions runners have this by default; self-hosted runners need Docker installed and the socket accessible. On some Windows/Docker Desktop combinations the npipe transport docker-java uses can fail outright (
BadRequestExceptionon every named pipe). If that happens locally, verify the flow manually against the compose Postgres instead (make docker-up, run the packaged jar, curl the endpoints) and trust CI for the actual Testcontainers run. - JWT parsing has no clock-skew leeway configured; a dev machine with a wrong system clock will see intermittent 401s on freshly issued tokens.
- Bucket4j buckets are in-memory and per-instance: they reset on restart and don't coordinate across multiple app instances. Fine for a template or single-instance deploy; scaling horizontally would need a Redis-backed bucket store.
make db-generateonly scaffolds an empty timestamped file. Flyway Community has no entity-diff autogeneration, so write the SQL by hand.- No
test:watchcommand. The JVM/Maven ecosystem has no idiomatic file-watch test runner without a third-party plugin, so re-runmake testmanually or use your IDE's test runner. - Flipping the last character of a base64url-encoded JWT to "tamper" it in a test can decode to the exact same bytes (unused padding bits in the final character), so the "tampered" token verifies successfully anyway and the test goes flaky. Flip a character in the middle instead.