Building a Production-Ready REST API with Spring Boot, Spring Security, JPA, and Docker

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

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 = 1000 in postgresql.conf to 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.

Share: