Configuration, Profiles & Actuator

Project: A Production-Ready Configuration

18 min Lesson 10 of 13

Project: A Production-Ready Configuration

Throughout this tutorial you have learned each ingredient in isolation: externalised configuration, type-safe @ConfigurationProperties, Spring profiles, YAML, secrets from environment variables, Actuator endpoints, health checks, Micrometer metrics, and securing the management layer. This final lesson assembles every piece into a single, cohesive Spring Boot 3 application that you could hand to an operations team on Monday morning. The goal is not to introduce new APIs — it is to show how the pieces fit together and to discuss the production trade-offs that matter most.

Project Overview

We will build a minimal Order Processing Service: a REST API that accepts orders, persists them via a JPA repository, and exposes a custom Actuator health indicator and Micrometer metric. The real lesson is in the configuration scaffolding that surrounds it.

Focus on the scaffolding, not the domain. The order domain is intentionally trivial. What matters is understanding how configuration, profiles, secrets, and observability are wired together so the same pattern applies to any service you build.

Step 1 — Type-Safe Configuration Bean

All application-specific knobs live in one validated POJO. Never scatter @Value injections across dozens of classes — it makes refactoring painful and hides the full configuration surface from the reader.

package com.example.orders.config; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @Validated @ConfigurationProperties(prefix = "app") public record AppProperties( @NotBlank String name, @NotBlank String downstreamApiUrl, @Min(1) int maxOrdersPerMinute, boolean detailedErrors ) {}

Enable it in the main class (or any @Configuration class):

@SpringBootApplication @EnableConfigurationProperties(AppProperties.class) public class OrdersApplication { public static void main(String[] args) { SpringApplication.run(OrdersApplication.class, args); } }

Step 2 — YAML Configuration with Profiles

Use three YAML files: a base file that holds universal defaults, a dev overlay that relaxes security and enables verbose logging, and a prod overlay that tightens everything down.

# application.yml (base — always loaded) spring: application: name: orders-service datasource: url: ${DB_URL} username: ${DB_USER} password: ${DB_PASSWORD} jpa: open-in-view: false # never leave this true in prod app: name: Orders Service downstream-api-url: ${DOWNSTREAM_API_URL} max-orders-per-minute: 100 detailed-errors: false management: endpoints: web: base-path: /manage exposure: include: health,info,metrics,prometheus endpoint: health: show-details: when_authorized info: env: enabled: true info: app: name: ${app.name} version: '@project.version@'
# application-dev.yml (activated by SPRING_PROFILES_ACTIVE=dev) app: downstream-api-url: http://localhost:9090 detailed-errors: true management: endpoint: health: show-details: always # convenient locally, not safe in prod logging: level: com.example.orders: DEBUG org.springframework.web: DEBUG
# application-prod.yml (activated by SPRING_PROFILES_ACTIVE=prod) app: max-orders-per-minute: 500 spring: jpa: show-sql: false logging: level: root: WARN com.example.orders: INFO
Keep secrets out of YAML entirely. Every sensitive value (DB_PASSWORD, DOWNSTREAM_API_URL, API keys) is referenced via ${ENV_VAR} placeholders and injected by the deployment platform (Kubernetes Secrets, AWS Parameter Store, Docker secrets). The YAML files are committed to version control — secrets must never be.

Step 3 — Custom Health Indicator

A custom HealthIndicator lets Actuator report whether a downstream dependency is reachable. Kubernetes liveness and readiness probes call /manage/health — a failing indicator takes the pod out of the load-balancer rotation automatically.

package com.example.orders.health; import com.example.orders.config.AppProperties; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @Component("downstreamApi") public class DownstreamApiHealthIndicator implements HealthIndicator { private final RestClient restClient; private final String pingUrl; public DownstreamApiHealthIndicator(AppProperties props) { this.pingUrl = props.downstreamApiUrl() + "/health"; this.restClient = RestClient.create(); } @Override public Health health() { try { restClient.get() .uri(pingUrl) .retrieve() .toBodilessEntity(); return Health.up() .withDetail("url", pingUrl) .build(); } catch (Exception ex) { return Health.down() .withDetail("url", pingUrl) .withDetail("error", ex.getMessage()) .build(); } } }

Spring Boot automatically discovers any @Component implementing HealthIndicator. The bean name ("downstreamApi") becomes the key in the /manage/health JSON response.

Step 4 — Business Metric with Micrometer

Register a counter so the ops team can graph order throughput in Grafana without parsing log files.

package com.example.orders.service; import com.example.orders.domain.Order; import com.example.orders.domain.OrderRepository; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final OrderRepository repository; private final Counter ordersCreated; public OrderService(OrderRepository repository, MeterRegistry registry) { this.repository = repository; this.ordersCreated = Counter.builder("orders.created") .description("Total orders created") .tag("service", "orders-service") .register(registry); } @Transactional public Order createOrder(Order order) { Order saved = repository.save(order); ordersCreated.increment(); return saved; } }

Step 5 — Securing the Management Endpoints

Expose management endpoints on a separate port so your firewall can block them from the public internet, and require HTTP Basic authentication for sensitive endpoints.

# In application.yml — management on its own port management: server: port: 8081
package com.example.orders.config; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration public class ActuatorSecurityConfig { @Bean SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception { http .securityMatcher(EndpointRequest.toAnyEndpoint()) .authorizeHttpRequests(auth -> auth .requestMatchers(EndpointRequest.to("health", "info")).permitAll() .anyRequest().hasRole("OPS") ) .httpBasic(org.springframework.security.config.Customizer.withDefaults()); return http.build(); } }
Never expose /manage/env, /manage/heapdump, or /manage/shutdown to the internet. The env endpoint dumps all resolved properties — including secrets that Spring has read from environment variables. Always restrict sensitive endpoints to internal networks or authenticated operator roles.

Step 6 — Prometheus Scrape Endpoint

Adding the Micrometer Prometheus registry to the classpath automatically creates a /manage/prometheus endpoint that Prometheus can scrape every 15 seconds. This is all you need in pom.xml:

<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>

A typical Prometheus scrape config:

# prometheus.yml scrape_configs: - job_name: 'orders-service' metrics_path: '/manage/prometheus' static_configs: - targets: ['orders-service:8081'] basic_auth: username: ops password: secret

Putting It All Together — The Production Checklist

Before deploying any Spring Boot service, run through this checklist:

  1. Secrets are environment variables — no credentials in YAML or source control.
  2. @ConfigurationProperties with @Validated — startup fails fast if required config is absent; no silent null values at runtime.
  3. Active profile set by the platformSPRING_PROFILES_ACTIVE=prod injected by Kubernetes or the CI/CD pipeline, not hardcoded.
  4. Management on a separate portmanagement.server.port=8081 isolates the management plane from the application plane.
  5. Health endpoint used by the orchestrator — liveness probe: /manage/health/liveness; readiness probe: /manage/health/readiness.
  6. Custom HealthIndicator for every critical dependency — database, message broker, downstream APIs.
  7. Business metrics registered at startup — counters, timers, and gauges that answer operational questions without log parsing.
  8. Prometheus endpoint protected by HTTP Basic or mTLS — metrics can leak domain knowledge to attackers.
  9. open-in-view: false — prevents lazy-loading queries from firing in the web layer, a common N+1 trap in production JPA applications.
  10. Version in /manage/info — use the '@project.version@' Maven filter so every running instance reports its exact artifact version.
Treat configuration as code. Your YAML files, Actuator security rules, and health indicators are as important as your business logic. Review them with the same rigour, test them with Spring Boot's @SpringBootTest(properties = {...}), and keep them in version control alongside your Java sources.

Summary

A production-ready Spring Boot service is not just correct business logic — it is observable, safely configured, and operationally self-describing. By combining @ConfigurationProperties (type-safe, validated, refactorable), multi-stage YAML profiles (base / dev / prod), environment-variable secrets, custom HealthIndicator beans, Micrometer counters, and a secured Actuator management plane, you give your operations team everything they need to run the service with confidence. This is the configuration standard to apply to every service you build going forward.