Context & Why This Stack
It was 2:47 AM when the alert came in. Our Node.js microservice was throwing 503s under load — the connection pool to PostgreSQL had saturated completely. By 4 AM, we’d made the call: migrate the critical billing service to Spring Boot. Not because Java is trendy, but because we needed predictable threading, connection handling that doesn’t buckle under concurrency spikes, and a security layer we didn’t have to build ourselves.
That was eighteen months ago. The billing service hasn’t had a single pool exhaustion since. Millions of transactions, zero panics.
Spring Boot with JPA and Spring Security gives you three things that matter at 2 AM:
- Spring Security — JWT auth, CSRF protection, and role-based access without writing security logic from scratch
- Spring Data JPA — Database abstraction with built-in connection pooling; prevents the N+1 query disasters that saturate connections under load
- Docker — Reproducible deployments so “works on my machine” stops being a 4 AM excuse
Installation
Prerequisites
You need Java 17+, Maven 3.8+, Docker, and PostgreSQL. Use SDKMAN for Java version management — switching JDKs mid-project without it is miserable:
# Install SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Install Java 17
sdk install java 17.0.9-tem
java -version
Bootstrap the Project
Use Spring Initializr from the CLI — faster than the web UI when you’re spinning up at midnight:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.2.0 \
-d baseDir=my-api \
-d groupId=com.example \
-d artifactId=my-api \
-d dependencies=web,data-jpa,security,postgresql,lombok,validation \
-o my-api.zip && unzip my-api.zip
That gives you a pom.xml with the four dependencies that do the heavy lifting:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Configuration
Database & JPA Setup
Configure application.yml — YAML over .properties because a mismatched indentation is easier to spot than a missing period at 2 AM:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # NEVER use create-drop in production
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
The HikariCP pool settings aren’t arbitrary. PostgreSQL defaults to 100 max connections. Run two app instances at 20 connections each and you’ve used 40 — leaving 60 for your DBA, monitoring queries, and that inevitable emergency admin session. Hit the ceiling and every new connection blocks.
Entity, Repository, and Controller
// Product.java
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
@NotBlank(message = "Product name is required")
private String name;
@Column(nullable = false)
@DecimalMin(value = "0.0", message = "Price must be positive")
private BigDecimal price;
@CreationTimestamp
private LocalDateTime createdAt;
}
// ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
List<Product> findByPriceRange(@Param("min") BigDecimal min,
@Param("max") BigDecimal max);
}
// ProductController.java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Validated
public class ProductController {
private final ProductRepository productRepository;
@GetMapping
public ResponseEntity<List<Product>> getAll() {
return ResponseEntity.ok(productRepository.findAll());
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody Product product) {
Product saved = productRepository.save(product);
URI location = URI.create("/api/v1/products/" + saved.getId());
return ResponseEntity.created(location).body(saved);
}
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
return productRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!productRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
productRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Spring Security Configuration
Spring Security 6 (Boot 3.x) dropped WebSecurityConfigurerAdapter. Copy from a Stack Overflow answer before 2023 and it’ll compile but misconfigure silently. The component-based approach that actually works:
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // Stateless REST — no CSRF needed
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin")
.password(passwordEncoder().encode("secret"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
}
Dockerizing the Application
Multi-stage builds cut image size from ~500MB to ~150MB — that gap matters when you’re pulling images across a degraded network at midnight. The dependency:go-offline step is worth the extra cache layer: change only application code and Docker skips the Maven download entirely on the next build, saving 2–3 minutes per iteration:
# Dockerfile
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn clean package -DskipTests -q
# Runtime stage — smaller base, no JDK overhead
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8080:8080"
environment:
DB_USERNAME: postgres
DB_PASSWORD: secret
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
Verification & Monitoring
Running and Testing the API
# Start everything
docker-compose up -d
# Watch startup logs
docker-compose logs -f api
# Test public endpoint
curl http://localhost:8080/api/v1/products
# Create a product (requires Basic Auth)
curl -X POST http://localhost:8080/api/v1/products \
-u admin:secret \
-H "Content-Type: application/json" \
-d '{"name": "PostgreSQL Guide", "price": 29.99}'
# Returns: 201 Created with Location: /api/v1/products/1
Spring Actuator for Health Monitoring
Add the Actuator dependency and expose the endpoints — this is what saved us during a midnight incident when we needed connection pool status without SSH access:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# Add to application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
# Check application health
curl http://localhost:8080/actuator/health
# Response:
# {
# "status": "UP",
# "components": {
# "db": {"status": "UP"},
# "diskSpace": {"status": "UP"}
# }
# }
# Check HikariCP pool metrics
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
What to Watch in Production
After running this pattern across three services, these are the metrics that actually fire alerts:
- HikariCP active connections — alert when consistently above 80% of
maximum-pool-size - JVM heap usage — Spring Boot apps typically stabilize around 200–400MB; spikes usually mean a query returning too many rows
- PostgreSQL slow queries — set
log_min_duration_statement = 1000inpostgresql.confto catch anything over 1 second - Response time percentiles — add Micrometer + Prometheus for p99 tracking; the average hides the tail latency that users actually experience
One thing I’d tell my past self: set ddl-auto: validate from day one, not create or update. Hibernate’s schema management has no rollback. A migration that fails mid-deploy leaves you in manual recovery at the worst possible time. Once you’re past a solo developer, switch to Flyway for versioned migrations. Setup takes two hours. Every clean production deploy after that is the payoff.
