Skip to content
Jarviix

Tech · 6 min read

Spring Boot Best Practices for Production

Spring Boot accelerates Java backend development but ships with defaults that need tuning for production. Configuration, observability, performance, and security practices that matter.

By Jarviix Engineering · Apr 19, 2026

Java code on developer screen
Photo via Unsplash

Spring Boot has dominated Java backend development for nearly a decade. Its convention-over-configuration approach gets teams shipping quickly, and its ecosystem covers nearly every backend need. But "quick to start" doesn't mean "production-ready by default." Many of Spring Boot's defaults are tuned for getting started, not for running production workloads at scale.

This post covers the practices that distinguish production-grade Spring Boot applications from prototypes — configuration, observability, performance, security, and the patterns that experienced teams converge on.

Configuration management

Use profiles strategically

  • application.yml: defaults safe for any environment
  • application-dev.yml: development overrides
  • application-test.yml: test environment specifics
  • application-prod.yml: production overrides

Activate profile via SPRING_PROFILES_ACTIVE env var.

Externalize sensitive config

Never commit secrets. Use:

  • Environment variables for passwords, API keys
  • Secret managers (AWS Secrets Manager, Vault) for sensitive runtime data
  • Spring Cloud Config Server for centralized config

Type-safe configuration

Prefer @ConfigurationProperties over scattered @Value:

@ConfigurationProperties(prefix = "myapp.database")
public record DatabaseProperties(
    String url,
    String username,
    int connectionPoolSize,
    Duration connectionTimeout
) {}

Validates structure at startup; refactoring-safe; single source of truth.

Don't load all config at startup

For dynamic configuration (feature flags, runtime tuning), integrate with external systems (LaunchDarkly, AWS AppConfig, custom config server). Restarting the application to change a flag is a smell.

Observability

Use Spring Boot Actuator

Add spring-boot-starter-actuator. Provides:

  • /actuator/health: health checks for K8s/load balancers
  • /actuator/metrics: application metrics
  • /actuator/info: build info, git commit
  • /actuator/loggers: runtime log level changes

Secure actuator endpoints in production — don't expose them publicly.

Structured logging

Use SLF4J with structured logging:

@Slf4j
public class OrderService {
    public void process(Order order) {
        log.info("Processing order orderId={} userId={} amount={}", 
            order.getId(), order.getUserId(), order.getAmount());
    }
}

For JSON logging (preferred in production), use logstash-logback-encoder or spring-boot-starter-log4j2.

Metrics with Micrometer

Spring Boot integrates with Micrometer. Register custom metrics:

@Service
public class PaymentService {
    private final Counter paymentCounter;
    
    public PaymentService(MeterRegistry registry) {
        this.paymentCounter = Counter.builder("payments.processed")
            .tag("status", "success")
            .register(registry);
    }
    
    public void process(Payment p) {
        // ...
        paymentCounter.increment();
    }
}

Export to Prometheus, Datadog, CloudWatch, etc.

Distributed tracing

Add Spring Cloud Sleuth (Spring Boot 2.x) or Micrometer Tracing (Spring Boot 3.x). Auto-instruments HTTP calls, database queries, message queues. Visualize in Zipkin or Jaeger.

Critical for debugging in microservice architectures.

Database practices

Connection pooling

HikariCP is the default in Spring Boot 2+. Tune it:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 10000
      idle-timeout: 300000
      max-lifetime: 1200000

Pool size depends on database capacity and concurrency. Rule of thumb: pool_size = ((core_count * 2) + effective_spindle_count) for the database server.

Avoid N+1 queries

JPA's lazy loading creates these by default. Use:

  • @EntityGraph for fetch hints
  • JPQL JOIN FETCH for explicit loading
  • Repository methods with @Query
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.userId = :userId")
List<Order> findOrdersWithItems(Long userId);

Without this, fetching 100 orders triggers 101 queries (1 + 100 for items).

Use read replicas

For read-heavy workloads, route reads to replicas. Spring's AbstractRoutingDataSource enables this.

Pagination always

Never findAll() on tables that can grow. Use Pageable:

Page<Order> findByStatus(String status, Pageable pageable);

Transactions deliberately

  • Use @Transactional thoughtfully — every method = its own transaction is expensive
  • Set isolation levels explicitly when needed
  • Avoid long-running transactions
  • Read-only transactions: @Transactional(readOnly = true) for slight optimization

Performance

JVM tuning

Container-aware JVM (Java 11+ recognizes container limits automatically). Set heap appropriately:

-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200

For containers, prefer fixed heap to avoid resize jitter.

Caching

Use Spring Cache abstraction:

@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }

@CacheEvict(value = "users", key = "#user.id")
public void update(User user) { ... }

Backend: Caffeine (in-process) for hot data, Redis for distributed cache.

Async operations

For I/O-heavy work, use @Async and CompletableFuture:

@Async
public CompletableFuture<Result> fetchData(String id) {
    return CompletableFuture.completedFuture(externalApi.call(id));
}

Configure thread pools deliberately — don't rely on defaults.

Reactive when justified

Spring WebFlux for high-concurrency, low-CPU workloads. Don't reach for it just because "reactive is modern" — adds significant complexity. Most CRUD apps don't benefit.

Security

Authentication and authorization

Use Spring Security with established patterns:

  • OAuth2 for API authentication
  • JWT for stateless tokens
  • Method-level @PreAuthorize for fine-grained authorization

Don't roll your own auth. Spring Security handles edge cases you haven't thought of.

Input validation

Use Bean Validation (@Valid, @NotNull, @Pattern):

public record CreateUserRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password,
    @Pattern(regexp = "^[A-Za-z\\s]+$") String name
) {}

@PostMapping("/users")
public User create(@Valid @RequestBody CreateUserRequest request) { ... }

CSRF protection

Enabled by default in Spring Security. Disable only for stateless APIs, and only deliberately.

Security headers

server:
  servlet:
    session:
      cookie:
        secure: true
        http-only: true
        same-site: strict

Add Content-Security-Policy, X-Content-Type-Options, X-Frame-Options.

Secrets in environment, not code

Never:

@Value("${api.key}")
private String apiKey = "actual-key-here-as-default";  // DON'T

Always require env var; fail fast on missing.

Testing practices

Layered tests

  • Unit tests: pure logic, no Spring context
  • Slice tests: @WebMvcTest, @DataJpaTest for specific layers
  • Integration tests: @SpringBootTest with TestContainers for real databases

TestContainers for integration tests

Run real PostgreSQL/Redis/Kafka in Docker for tests:

@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

Far better than H2 (which behaves differently from production database).

Don't mock everything

Mock external dependencies; test internal logic with real implementations. Heavy mocking creates brittle tests that don't catch real issues.

Deployment

Production-ready containers

FROM eclipse-temurin:21-jre-jammy

RUN useradd -m appuser
USER appuser

COPY app.jar /app.jar
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "/app.jar"]

Use distroless or Alpine for smaller images if needed.

Graceful shutdown

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Allows in-flight requests to complete during deploys.

Liveness vs Readiness

  • Liveness: is the application running? (restart if not)
  • Readiness: is the application ready to serve traffic? (route traffic if yes)

Spring Boot Actuator provides both at /actuator/health/liveness and /actuator/health/readiness.

Common mistakes

  • Logging at INFO too liberally: floods logs; expensive at scale
  • Auto-configuration overload: every starter adds bean count; review what's actually needed
  • Eager loading by default: causes N+1 queries
  • Hardcoded thread pool sizes: doesn't scale with deployment environment
  • No request timeout configuration: hung requests consume resources indefinitely
  • Stack traces in API responses: leaks internal details
  • Default error handling: generic 500 errors instead of meaningful client responses
  • No graceful shutdown: deploys kill in-flight requests

Spring Boot's strength is also its weakness — getting started is easy, but the defaults aren't always production-grade. The patterns above represent what experienced Spring teams converge on after running real workloads. None are revolutionary; all are practical. Adopting them moves applications from "works in demo" to "runs reliably under load."

Frequently asked questions

What's the right way to manage configuration in Spring Boot?

application.yml/properties for non-sensitive defaults, profile-based files (application-prod.yml) for environment overrides, environment variables for environment-specific values, and external secret managers (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) for sensitive data. Never commit secrets to git. Use @ConfigurationProperties for type-safe binding rather than scattered @Value annotations. For runtime config changes, integrate with Spring Cloud Config or external feature flag systems.

Should I use Spring Data JPA or jOOQ/MyBatis?

JPA for typical CRUD-heavy applications where development speed matters and queries are mostly straightforward. jOOQ for complex SQL workloads, reporting, analytical queries — type-safe SQL with full database power. MyBatis for legacy migration or when you want SQL control without JPA's abstractions. JPA's strengths are also its weaknesses: hidden N+1 queries, lazy loading exceptions, and unpredictable SQL generation can hurt at scale. Many production systems use JPA for 80% of cases and jOOQ for the 20% requiring control.

How do I optimize Spring Boot startup time?

Several approaches: (1) Use Spring Boot 3+ with native compilation (GraalVM) — startup goes from seconds to milliseconds. (2) Lazy initialization (spring.main.lazy-initialization=true) for non-critical beans. (3) Minimize dependencies — every starter adds startup cost. (4) Use class data sharing (CDS) for the JVM. (5) Profile startup with Spring Boot Actuator's startup endpoint to identify slow beans. For containerized workloads, faster startup means faster scale-up; for serverless, it's critical.

Related Jarviix tools

Read paired with the calculator that does the math.

Read next