Production logging in Spring Boot (Logback defaults, SLF4J facade, switching to Log4j2 with zero code changes, structured logs), externalised configuration benefits and risks, the real property precedence order, mixing application.yml and application.properties, migrating properties to YAML safely, and customising and securing Actuator endpoints for monitoring and management.
Published September 25, 2026
At 5–8 years of experience, "how do you log?" really means how do you run this in production? Cover:
Short answer: Spring Boot ships spring-boot-starter-logging: SLF4J as the API, and Logback as the default implementation. Out of the box, it logs to the console with a sensible pattern at INFO level. You tune it through properties first, and a logback-spring.xml file for advanced setups.
logging:
level:
root: INFO
com.shopin.orders: DEBUG # per-package levels
org.hibernate.SQL: DEBUG # see the SQL (never in production at high volume)
file:
name: /var/log/orders/app.log # or log to stdout only, in containers
structured:
format:
console: ecs # Boot 3.4+: JSON logs (ecs / logstash / gelf) for ELK, Loki, Datadog
Key points to cover:
logback-spring.xml (not logback.xml) so you can use <springProfile> and <springProperty>. Plain logback.xml is loaded before Spring, so it can't see profiles./actuator/loggers endpoint, with no restart needed.traceId/spanId into the MDC, so every log line can be joined to a trace. Add your own MDC keys (like orderId) in a filter, and clear them afterwards.Learn it in depth → Centralized Logging
Short answer: SLF4J (Simple Logging Facade for Java) is a logging API (a facade), not a logging engine. Your code calls org.slf4j.Logger, and a binding chosen at deployment time does the actual output: Logback, Log4j2, or java.util.logging. That's how you can swap engines without touching the code.
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("Order {} placed by customer {} for {}", orderId, customerId, total); // parameterised: no string building if INFO is off
log.warn("Payment retry {} for order {}", attempt, orderId, ex); // a trailing exception prints its stack trace
Key points to cover:
{}) avoid string concatenation cost when the level is disabled. Also, never log secrets or full personal data.jul-to-slf4j, log4j-to-slf4j and jcl-over-slf4j, so every library's logs end up in one place.ServiceLoader providers, and adds a fluent API: log.atInfo().addKeyValue("orderId", id).log("placed").Short answer: The compile-time dependency is only slf4j-api. What prints the logs is decided by which provider jar is on the classpath at runtime. Libraries should depend only on the API, so the application decides the engine, and there are no conflicting logging setups.
Common trap: having two providers on the classpath (for example Logback and log4j-slf4j2-impl). SLF4J warns and picks one. In Boot this usually means you forgot to exclude spring-boot-starter-logging.
Short answer: None in the Java code, if you log through SLF4J. You only change the build and the configuration:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId> <!-- remove Logback -->
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId> <!-- add Log4j2 and its SLF4J provider -->
</dependency>
Key points to cover:
logback-spring.xml with log4j2-spring.xml.logging.level.* properties keep working, because Boot's logging abstraction supports both engines.spring-boot-starter-logging. A global exclusion (Gradle configurations.all { exclude ... }) is easiest.ch.qos.logback.*, for example custom appenders) does need changes. That's why business code should use only the SLF4J API.Short answer:
spring.config.import=vault://, aws-secretsmanager:), and encrypt at rest.@ConfigurationProperties with @Validated, so bad configuration fails at startup, not at 2 a.m.application.yml.@RefreshScope or a restart.@ConfigurationProperties(prefix = "payments")
@Validated
public record PaymentProperties(@NotNull URI gatewayUrl, @DurationMin(millis = 100) Duration timeout, @Min(0) @Max(5) int maxRetries) { }
Learn it in depth → Configuration Management
Short answer: Later sources override earlier ones. From lowest to highest precedence (a simplified version of the official list):
SpringApplication.setDefaultProperties).@PropertySource on @Configuration classes.application.properties/.yml inside the jar;application-prod.yml);application files outside the jar (./config/, the current directory);RandomValuePropertySource (random.*).SPRING_DATASOURCE_URL).-Dserver.port=9090).ServletContext/ServletConfig init parameters.SPRING_APPLICATION_JSON.--server.port=9090).@TestPropertySource, and @SpringBootTest(properties=...), which are highest of all in tests.Common trap: the source answer puts the files above environment variables. In fact, environment variables and system properties override application.yml. That's exactly why Kubernetes and Docker can set SPRING_PROFILES_ACTIVE or SERVER_PORT without rebuilding.
Key points to cover:
/actuator/env/{property}. It shows every source that defines the property, and which one wins.application.yml and application.properties? How are they prioritised?Short answer: Yes. Boot loads both and merges them. If the same key is in both in the same location, .properties wins over .yml. Keys that appear in only one file are used as-is.
Key points to cover:
application.yml beats a packaged application.properties.Short answer:
application.properties to application.yml, application-prod.properties to application-prod.yml), turning dotted keys into nested maps. An IDE or a converter tool helps.on/off/yes/no can become booleans in YAML 1.1, and a leading 0 can make a value octal. Quote passwords and IDs like "0123".:, #, *) need quotes.- item, or [a, b].---, with spring.config.activate.on-profile: prod. The old spring.profiles: key is deprecated.@PropertySource doesn't support YAML out of the box. Keep those files as .properties, or write a YAML PropertySourceFactory.Environment before and after (/actuator/env, or a test that asserts the key values). Run the integration tests for each profile..properties files in the same change, so the .properties-wins rule can't silently keep old values.Common trap: the migration "works", but a stale application.properties is still on the classpath and overriding the new YAML values.
Short answer:
health is exposed by default.management.endpoints.web.exposure.include=health,info,metrics,prometheus.* in production.management.endpoints.web.base-path=/manage.management.server.port=8081) that's only reachable internally.EndpointRequest matcher), and sanitise env and configprops values (management.endpoint.env.show-values=WHEN_AUTHORIZED)./actuator/health/liveness and /actuator/health/readiness.show-details=when-authorized.HealthIndicators.@Component
@Endpoint(id = "featureflags")
public class FeatureFlagsEndpoint {
private final FeatureFlagStore store;
FeatureFlagsEndpoint(FeatureFlagStore store) { this.store = store; }
@ReadOperation public Map<String, Boolean> all() { return store.all(); } // GET /actuator/featureflags
@ReadOperation public Boolean one(@Selector String name) { return store.get(name); } // GET /actuator/featureflags/{name}
@WriteOperation public void set(@Selector String name, boolean enabled) { store.set(name, enabled); } // POST
@DeleteOperation public void reset(@Selector String name) { store.reset(name); } // DELETE
}
@WebEndpoint for HTTP-only endpoints. Use **InfoContributor**s to add build, Git or custom data to /info.Learn it in depth → Health Checks
Short answer:
/health and its liveness/readiness groups drive load balancer and Kubernetes probes. Database, disk, broker and custom indicators decide whether the instance takes traffic./metrics and /prometheus, backed by Micrometer. JVM, HTTP (http.server.requests with percentiles), datasource pool, cache, executor and custom business metrics are scraped by Prometheus, and shown in Grafana with alerts./loggers: change log levels live;/threaddump and /heapdump: investigate hangs and leaks;/env and /configprops: resolved configuration;/beans, /conditions (why an auto-configuration did or didn't apply), /mappings;/scheduledtasks, /caches, /flyway or /liquibase./shutdown (disabled by default), and custom write operations (like the feature flags above). Spring Boot Admin gives a UI over all of this.Common trap: exposing /heapdump or /env publicly. A heap dump contains secrets and user data. Lock it down, or don't expose it at all.
Learn it in depth → Metrics & Monitoring
Q: How do you add a correlation ID to every log line for a request?
A: With Micrometer Tracing, traceId is already in the MDC. Include %X{traceId} in the log pattern, or use structured logging. For a custom ID, a OncePerRequestFilter reads or creates the X-Correlation-Id header, calls MDC.put, and clears the MDC in finally. Propagate it to asynchronous threads with a TaskDecorator.
Q: What's the cost of DEBUG logging in production?
A: CPU for formatting and I/O, larger log volumes and storage bills, and possible leakage of sensitive data. Enable it temporarily and per package through /actuator/loggers, and turn it off afterwards.
Q: How do you keep secrets out of application.yml?
A: Reference placeholders (${DB_PASSWORD}) resolved from environment variables or mounted Kubernetes secrets, or use spring.config.import with Vault, AWS Secrets Manager or Parameter Store, or a configuration server with encrypted values. Rotate the secrets, and never log the resolved values.
Q: What does /actuator/conditions help you debug?
A: The auto-configuration report: which auto-configurations matched or didn't, and why (for example "@ConditionalOnMissingBean found an existing DataSource"). It's invaluable when a bean you expected isn't created. --debug prints the same report at startup.