The stack you already have
The moment you add any Spring Boot starter, you get working logs for free. That's because every starter pulls
in spring-boot-starter-logging transitively, and that starter wires up the SLF4J facade in front of the
Logback engine. You write code against SLF4J's Logger, and Logback turns those calls into the neatly
formatted, colored lines you see on the console:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
// Code against SLF4J — Logback runs underneath
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void place(String id) {
log.info("Placing order {}", id); // {} placeholder, not concatenation
}
}
Why a facade? Because it decouples your code from the logging library. Your classes only ever import
org.slf4j.Logger, so whether the output ends up in Logback, Log4j2, or java.util.logging is a build-time
decision rather than a code change. Spring Boot even bridges the older APIs — Spring Framework's own internals
use Commons Logging, and spring-jcl quietly routes those calls onto SLF4J so the framework's logs and
your logs share a single configuration and a single output stream. One pipeline, one config: that's the whole
point of logging through a facade.
Levels and groups
Most day-to-day logging configuration is just setting levels, and you do that with plain properties — no
XML required. Use logging.level.<package> for a specific area and logging.level.root for the global
default. Levels are hierarchical, so a setting on a package cascades to everything beneath it:
logging:
level:
root: INFO # global default
org.springframework.web: DEBUG # one package
com.example.OrderService: TRACE # a single class
org.hibernate.SQL: DEBUG # watch the SQL fly by
When you find yourself flipping several related packages together, reach for a logging group, which maps
one name onto many packages. Spring Boot ships two built-ins — web and sql — and you can define your own:
logging:
group:
payments: com.example.billing,com.example.gateway # your own group
level:
web: DEBUG # built-in: Spring web, codecs, HTTP clients
sql: DEBUG # built-in: JDBC + Hibernate SQL
payments: TRACE # flips both custom packages at once
During an incident the web and sql groups are gold — one toggle and you're tracing requests or SQL without
remembering every package name. The discipline that matters: keep root at INFO in production and raise
levels narrowly. Flipping the whole app to DEBUG floods you with noise and can leak sensitive data.
Writing to a file, and not filling the disk
By default Boot logs only to the console, which is exactly right for containers — let the platform collect
stdout. On a VM where you want a local file to tail, set one of logging.file.name (an exact path) or
logging.file.path (a directory, where Boot writes spring.log):
logging:
file:
name: /var/log/myapp/app.log # exact file (console output continues too)
logback:
rollingpolicy:
max-file-size: 50MB # roll when a file reaches this size
max-history: 14 # keep 14 days of archives
total-size-cap: 2GB # hard ceiling across all archives
file-name-pattern: ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz # gzip old files
The instant you configure a file, Logback's rolling policy kicks in, and Boot exposes its knobs under
logging.logback.rollingpolicy.*. Do not skip max-history and total-size-cap. An unbounded log file grows
until it fills the disk, and a full disk is a classic 3am outage that takes the whole app down with it. Set the
ceilings once and forget about them.
Customizing with logback-spring.xml
Properties cover levels, files, and rotation. When you need more — custom appenders, conditional config,
environment-specific behavior — you graduate to an XML file. Here's the subtle but important bit: name it
logback-spring.xml, not logback.xml. Plain logback.xml is read by Logback very early, before
Spring's environment even exists, so Spring's extensions don't work. The -spring variant is loaded by Boot
itself, which means you get <springProfile> and <springProperty>:
<configuration>
<!-- start from Boot's sensible console/file defaults -->
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<!-- pull a value from Spring's Environment into a Logback variable -->
<springProperty scope="context" name="appName" source="spring.application.name"/>
<springProfile name="dev"> <!-- only when 'dev' is active -->
<root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
</springProfile>
<springProfile name="prod"> <!-- only when 'prod' is active -->
<root level="INFO"><appender-ref ref="FILE"/></root>
</springProfile>
</configuration>
This lets a single config behave differently per environment — verbose, colored console in dev; lean file or
JSON output in prod — instead of juggling separate files. <springProperty> injects any property from
application.yml, env vars, or the command line into your Logback setup, so your appender paths and names stay
in one source of truth.
Structured JSON for log aggregation
Pretty console text is for humans. Once your logs flow into Elasticsearch, Loki, or Splunk, you want machine-parseable JSON so every field — level, logger, MDC keys, stack traces — is indexed and queryable. Since Spring Boot 3.4 this is built in, no extra library. Point the format at a known schema and Boot emits one JSON object per line:
logging:
structured:
format:
console: ecs # Elastic Common Schema JSON on stdout
file: logstash # a different schema for the file appender
file:
name: /var/log/myapp/app.json
ecs, logstash, and gelf are supported out of the box, and you can register a custom format if your
aggregator wants a particular shape. Before 3.4 you'd add logstash-logback-encoder and hand-configure an
encoder; now it's a one-line property. The practical move is to switch the console appender to structured
JSON whenever logs are centrally collected — the platform reads the JSON, and you read the aggregator's UI.
Changing levels at runtime
Some bugs only show up in production, and you don't want to redeploy just to add a DEBUG line. The actuator
/loggers endpoint lets you change a logger live. A GET shows current levels; a POST with a
configuredLevel body changes one on the fly:
# Inspect, then raise to DEBUG without a restart
curl localhost:8080/actuator/loggers/com.example.OrderService
curl -X POST localhost:8080/actuator/loggers/com.example.OrderService \
-H 'Content-Type: application/json' \
-d '{"configuredLevel":"DEBUG"}'
# POST {"configuredLevel": null} later to reset it to the inherited level
You need to expose it (management.endpoints.web.exposure.include=loggers) and, crucially, secure it — it
changes runtime behavior, so it has no business being open to the world. Used carefully, it's the safest way to
get DEBUG output during a live incident: flip the one package you care about, capture the logs, then set it
back to null.
MDC and correlation IDs
In a busy service, logs from many concurrent requests interleave. To follow a single request through the noise,
stamp every line with a correlation ID using the MDC (Mapped Diagnostic Context) — a per-thread
key/value map SLF4J maintains, whose values you print with %X{key}:
// In a servlet filter, at the start of each request
MDC.put("traceId", request.getHeader("X-Trace-Id")); // or a generated UUID
try {
chain.doFilter(request, response); // every log now carries traceId
} finally {
MDC.clear(); // CRITICAL: pooled threads are reused — clear or context leaks
}
# Reference the MDC key in your pattern
logging.pattern.level=%5p [%X{traceId:-}]
If you use Micrometer Tracing, Boot populates traceId and spanId in the MDC for you. The one rule you can
never break: clear the MDC in a finally, because servlet threads are pooled and reused, and a leftover
correlation ID will silently attach itself to the next, unrelated request.
Best practices worth internalizing
A few habits separate clean logs from a liability. Always use SLF4J's {} placeholders rather than string
concatenation — the message is only built if the level is enabled, and for genuinely expensive arguments guard
with if (log.isDebugEnabled()). Pick levels deliberately: ERROR for things that need a human, WARN
for recoverable trouble, INFO for milestones, DEBUG/TRACE for diagnostics. Treat every log line as
public and permanent — never log passwords, tokens, full card numbers, or PII. And lean on Lombok's
@Slf4j to generate the log field so you stop copy-pasting LoggerFactory lines. When you need to diagnose
startup, remember --debug is not root=DEBUG: it enables DEBUG on a curated set of core loggers and prints
the auto-configuration report, which is exactly what you want when a bean mysteriously didn't get wired.
Recap
Spring Boot hands you a complete logging stack — SLF4J in front of Logback — with zero configuration, and most
tuning is just properties: levels, groups, a file path, and rotation limits. Graduate to logback-spring.xml
when you need profiles and injected properties, switch the console to structured JSON once logs flow into an
aggregator, and keep the actuator /loggers endpoint handy (and secured) for runtime level changes. Stamp
requests with an MDC correlation ID and clear it in a finally, use {} placeholders everywhere, keep secrets
out of the logs, and bound your files so they can never fill the disk. Get those right and your logs become the
first tool you trust when something breaks.