Production Configuration

Configure the bookings API for production: environment-driven settings, validated business rules, health probes, graceful shutdown and JSON logs.

SyntaxSPRING_PROFILES_ACTIVE=prod DB_URL=jdbc:postgresql://db:5432/bookings OIDC_ISSUER_URI=https://login.example.com/realms/staff @ConfigurationProperties("app.bookings") @Validated record BookingRules(@NotNull Duration minNotice) { }

The code is finished; now it has to run for months without surprises. Production configuration is mostly a set of small, deliberate settings, each of which prevents a specific failure you would otherwise meet at three in the morning.

One artefact, configuration from outside

The same JAR or image runs in every environment. application.yaml holds safe defaults, application-prod.yaml holds production choices, and anything that differs per deployment or is secret comes from environment variables: the database URL and password, the token issuer. Activate the profile with SPRING_PROFILES_ACTIVE=prod. Never put a real password in a YAML file, even in a private repository.

Business rules as validated properties

The minimum notice and maximum length are bound to a @ConfigurationProperties record with @Validated. A typo such as max-length: 8, which the binder reads as 8 milliseconds, would make every booking fail; a @DurationMin constraint stops the application at startup with a clear message instead.

Health, probes and the management port

Actuator serves on a separate port that the load balancer does not route, so /actuator is never public; that is what makes it safe for the security configuration to permit the health and Prometheus paths without a token. Liveness only says the process is working. Readiness includes the database, so a pod that cannot reach PostgreSQL stops receiving traffic without being restarted. Putting the database in liveness instead makes a database blip restart every pod at once.

Shutdown and threads

server.shutdown=graceful is the default, and finishes in-flight requests during a deployment; give it a timeout shorter than the platform's kill timeout. Virtual threads suit this API, which spends most of its time waiting on PostgreSQL, but they do not create database connections: the Hikari pool size remains the real limit on concurrency, so size it against what the database allows across every replica.

Logs and errors

Structured JSON logs on the console let the log platform index fields such as trace IDs instead of parsing text. Error responses must never include stack traces or exception messages; Spring Boot's defaults already keep them out, so set them explicitly to make the intent visible and to stop someone switching them on for a quick debug.

Behind the proxy

TLS ends at the load balancer, so set server.forward-headers-strategy=framework. Then the Location header on a 201 says https://bookings.example.com rather than the pod's internal address. Only enable it when a trusted proxy strips client-supplied forwarded headers.

Example

Example · yaml
# src/main/resources/application-prod.yaml
spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      connection-timeout: 3s
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  threads:
    virtual:
      enabled: true
  lifecycle:
    timeout-per-shutdown-phase: 20s
  data:
    web:
      pageable:
        max-page-size: 100
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${OIDC_ISSUER_URI}
          audiences:
            - bookings-api

server:
  shutdown: graceful
  forward-headers-strategy: framework
  error:
    include-stacktrace: never
    include-message: never

management:
  server:
    port: 8081
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: readinessState,db

logging:
  structured:
    format:
      console: ecs
  level:
    root: info

app:
  bookings:
    min-notice: 15m
    max-length: 8h

When to use it

  • Deploy the bookings API to Kubernetes with probes that take a pod out of rotation while its database connection recovers.
  • Shorten the maximum booking length for one office by changing an environment variable, with no rebuild.
  • Roll out a new version during working hours without cutting off requests that are halfway through.

More examples

Business rules bound and validated at startup

Because the application class has <code>@ConfigurationPropertiesScan</code>, this record is registered without any extra configuration, and <code>BookingPolicy</code> takes it through its constructor. The Hibernate Validator duration constraints catch a missing unit (<code>15</code> instead of <code>15m</code>), which the binder would read as milliseconds, before the first request arrives. Changing a rule is then a configuration change and a restart, not a release.

Example · java
import java.time.Duration;

import jakarta.validation.constraints.NotNull;

import org.hibernate.validator.constraints.time.DurationMax;
import org.hibernate.validator.constraints.time.DurationMin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * app.bookings.min-notice=15m
 * app.bookings.max-length=8h
 */
@ConfigurationProperties("app.bookings")
@Validated
public record BookingRules(
        @NotNull @DurationMin(minutes = 1) @DurationMax(hours = 24) Duration minNotice,
        @NotNull @DurationMin(minutes = 15) @DurationMax(hours = 24) Duration maxLength) {
}

A container image

The layered extract puts dependencies, which rarely change, in their own image layers, so a code change rebuilds and pushes only the small application layer. The runtime image has no JDK or build tools and runs as an unprivileged user. <code>MaxRAMPercentage</code> sizes the heap from the container's memory limit; tests run in CI before this build, which is why the image build skips them.

Example · dockerfile
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -q dependency:go-offline
COPY src/ src/
RUN ./mvnw -q -DskipTests package && \
    java -Djarmode=tools -jar target/bookings-*.jar extract --layers --launcher --destination extracted

FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app
USER app
WORKDIR /app
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
EXPOSE 8080 8081
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "org.springframework.boot.loader.launch.JarLauncher"]

Running it with configuration from the environment

<code>APP_BOOKINGS_MAX_LENGTH</code> overrides <code>app.bookings.max-length</code> through relaxed binding, which is how one image serves sites with different rules. The password is read from a mounted secret, not typed into a script or stored in shell history, though anyone who can run <code>docker inspect</code> can still see environment values. On Kubernetes the same variables come from a ConfigMap and a Secret, and the two health URLs become the liveness and readiness probes.

Example · bash
docker run --rm -p 8080:8080 -p 8081:8081 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e DB_URL=jdbc:postgresql://db.internal:5432/bookings \
  -e DB_USER=bookings_app \
  -e DB_PASSWORD="$(cat /run/secrets/bookings_db_password)" \
  -e OIDC_ISSUER_URI=https://login.example.com/realms/staff \
  -e APP_BOOKINGS_MAX_LENGTH=4h \
  bookings-api:1.0.0

# The probes a platform such as Kubernetes would call:
curl -s localhost:8081/actuator/health/liveness
curl -s localhost:8081/actuator/health/readiness

Discussion

  • Be the first to comment on this lesson.