Spring Boot

Logback

Logback is Spring Boot's default logging backend, and for many apps a few application.yml properties are all the configuration you need. When you outgrow that, logback-spring.xml gives you profile-specific appenders, rolling policies, async logging, and filters. This entry starts with the properties-only approach, then covers the XML file, rolling and async setup, filtering, and MDC, including the mistakes that silently break log output: multi-line patterns, mis-set async thresholds, and filters that do not exist.

Start With Properties, Not XML

Before writing any XML, check whether application.yml already covers you. Boot exposes the common knobs as properties: log levels per package, a log file with size-and-time rotation, custom patterns, and (from Spring Boot 3.4) built-in structured JSON output. If all you want is a rotating file and a couple of level overrides, an XML file is extra maintenance for nothing. Reach for logback-spring.xml when you need things properties cannot express: multiple appenders, different behaviour per profile, async wrapping, or custom filters. If you run in containers, log to stdout and let the platform collect it. Writing rotating files inside a container is usually the wrong layer to solve it at.
yaml
# application.yml
logging:
  level:
    root: INFO
    com.myapp: DEBUG
    org.springframework.web: WARN
    org.hibernate.SQL: DEBUG                       # SQL statements
    org.hibernate.orm.jdbc.bind: TRACE             # bound parameter values (Hibernate 6)

  file:
    name: logs/myapp.log                           # enables file logging

  logback:
    rollingpolicy:
      max-file-size: 20MB
      max-history: 14
      total-size-cap: 1GB
      clean-history-on-start: false

  pattern:
    console: "%d{HH:mm:ss.SSS} %5p [%X{traceId:-}] %-40.40logger{39} : %m%n%wEx"

  # Spring Boot 3.4+: JSON logs with no extra dependency (ecs, logstash, or gelf)
  structured:
    format:
      console: ecs

# Change a level on a running app (expose the loggers endpoint first):
#   management.endpoints.web.exposure.include: health,loggers
#   curl -X POST localhost:8080/actuator/loggers/com.myapp \
#        -H 'Content-Type: application/json' -d '{"configuredLevel":"TRACE"}'

logback-spring.xml Structure

Name the file logback-spring.xml, not logback.xml. Logback reads logback.xml very early, before Spring is ready, so the Spring extensions (springProfile, springProperty) only work in the -spring variant. Include Boot's defaults.xml. It defines the CONSOLE_LOG_PATTERN and FILE_LOG_PATTERN variables, the colour converters, and sensible levels for noisy libraries, so you can reuse Boot's own format instead of rewriting it. Two things that cause trouble. First, keep each pattern and fileNamePattern on a single line. Line breaks and indentation inside a <pattern> element end up in the output, so a tidy multi-line pattern produces log lines split across several rows. Second, avoid scan="true" in production. Auto-reload happens inside Logback, outside Spring's control, so the Spring-specific tags may not resolve on reload, and it adds a polling thread for little benefit; use the Actuator loggers endpoint to change levels at runtime instead.
XML
<!-- src/main/resources/logback-spring.xml -->
<configuration>

  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <springProperty scope="context" name="appName" source="spring.application.name" defaultValue="application"/>
  <springProperty scope="context" name="logPath" source="logging.file.path" defaultValue="logs"/>

  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>${CONSOLE_LOG_PATTERN}</pattern>
      <charset>${CONSOLE_LOG_CHARSET}</charset>
    </encoder>
  </appender>

  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${logPath}/${appName}.log</file>
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [${appName},%X{traceId:-},%X{correlationId:-}] [%t] %-40.40logger{39} : %m%n%wEx</pattern>
    </encoder>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <fileNamePattern>${logPath}/${appName}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
      <maxFileSize>10MB</maxFileSize>
      <maxHistory>30</maxHistory>
      <totalSizeCap>1GB</totalSizeCap>
    </rollingPolicy>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="FILE"/>
  </root>

  <!-- additivity="false" stops the event also reaching root, which would log it twice. -->
  <logger name="com.myapp" level="DEBUG" additivity="false">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="FILE"/>
  </logger>
  <logger name="org.springframework" level="WARN"/>

</configuration>

Profile-Based Configuration

<springProfile> lets one file describe every environment. A sensible split: readable coloured output for local work, JSON on stdout everywhere else so a log shipper can parse it without regexes. For JSON you have two options. On Spring Boot 3.4 and later, the built-in structured logging (shown in the properties section) needs no XML and no dependency. On older versions, or if you want Logstash-specific fields, add net.logstash.logback:logstash-logback-encoder; Boot does not manage that version, so pin a current release yourself. Remember that a profile block only applies when that profile is active. If both staging and prod are somehow active, both root loggers apply and every line is written twice.
XML
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <!-- Local development: coloured, human-readable -->
  <springProfile name="dev,default">
    <appender name="CONSOLE_DEV" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%cyan(%d{HH:mm:ss.SSS}) %highlight(%-5p) [%magenta(%X{correlationId:-none})] %yellow(%-40.40logger{39}) : %m%n%wEx</pattern>
      </encoder>
    </appender>
    <root level="DEBUG">
      <appender-ref ref="CONSOLE_DEV"/>
    </root>
    <logger name="com.myapp" level="TRACE"/>
  </springProfile>

  <!-- Everything else: JSON to stdout, wrapped in an async appender -->
  <springProfile name="staging,prod">
    <springProperty scope="context" name="appName" source="spring.application.name" defaultValue="application"/>

    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <customFields>{"app":"${appName}"}</customFields>
        <includeMdc>true</includeMdc>
      </encoder>
    </appender>

    <appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="JSON"/>
      <queueSize>1024</queueSize>
      <discardingThreshold>0</discardingThreshold>
      <neverBlock>false</neverBlock>
    </appender>

    <root level="INFO">
      <appender-ref ref="ASYNC_JSON"/>
    </root>
    <logger name="com.myapp" level="INFO"/>
  </springProfile>

</configuration>

Rolling Policies

Logback has three common ways to roll a file. TimeBasedRollingPolicy rotates on a date pattern; SizeAndTimeBasedRollingPolicy does the same and also splits within a period when a file gets too big (this is the one most apps want); FixedWindowRollingPolicy keeps a fixed number of numbered backups and is triggered by size. Details that matter: the fileNamePattern controls both the rotation period and whether files are compressed (a .gz or .zip suffix compresses them). With size-and-time rotation the pattern must contain both a date (%d) and an index (%i). totalSizeCap only takes effect when maxHistory is also set, and old files are deleted when either limit is exceeded. Neither limit will save you if the active log file itself grows without bound between rollovers, so cap file size.
XML
<!-- Size and time: the usual choice -->
<appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>logs/application.log</file>
  <encoder>
    <pattern>%d{ISO8601} %-5p [%t] %logger{36} : %m%n</pattern>
  </encoder>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>logs/application-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
    <maxFileSize>50MB</maxFileSize>
    <maxHistory>14</maxHistory>
    <totalSizeCap>2GB</totalSizeCap>
  </rollingPolicy>
</appender>

<!-- Fixed window: keeps app.1.log.gz ... app.5.log.gz, oldest dropped -->
<appender name="WINDOWED" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>logs/app.log</file>
  <encoder><pattern>%d %-5p %logger{36} : %m%n</pattern></encoder>
  <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
    <fileNamePattern>logs/app.%i.log.gz</fileNamePattern>
    <minIndex>1</minIndex>
    <maxIndex>5</maxIndex>
  </rollingPolicy>
  <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
    <maxFileSize>20MB</maxFileSize>
  </triggeringPolicy>
</appender>

<!-- ERROR-only file, kept longer than the main log. LevelFilter matches one exact level. -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>logs/error.log</file>
  <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>ERROR</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
  </filter>
  <encoder><pattern>%d{ISO8601} %-5p [%X{correlationId:-}] %logger{36} : %m%n%wEx</pattern></encoder>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>logs/error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
    <maxFileSize>10MB</maxFileSize>
    <maxHistory>90</maxHistory>
    <totalSizeCap>1GB</totalSizeCap>
  </rollingPolicy>
</appender>

Async Appender

A synchronous appender makes the request thread wait for the write. AsyncAppender puts events on a bounded queue and a separate thread drains it, which takes disk or network latency off your hot path. The defaults surprise people. The queue holds 256 events, and discardingThreshold defaults to queueSize/5: once the remaining free capacity falls below that number, Logback starts dropping TRACE, DEBUG, and INFO events. So a burst of traffic can lose exactly the INFO lines you wanted. Setting discardingThreshold to 0 stops the dropping. That leaves the real trade-off. With neverBlock=false, a full queue blocks the calling thread, which protects your logs but can stall requests if the appender behind it is slow. With neverBlock=true, requests never wait, but events are dropped when the queue is full. Pick based on which loss you can tolerate, and make the queue big enough that it rarely matters. Keep includeCallerData=false unless your pattern prints class, method, or line numbers; capturing them is expensive.
XML
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
  <appender-ref ref="APP_FILE"/>

  <!-- Default is 256. Raise it so bursts are absorbed instead of blocking or dropping. -->
  <queueSize>2048</queueSize>

  <!-- Default is queueSize/5: below this many free slots, TRACE/DEBUG/INFO are discarded.
       0 disables discarding entirely. -->
  <discardingThreshold>0</discardingThreshold>

  <!-- false: block the caller when full (no loss, possible stall).
       true:  never block (no stall, possible loss). -->
  <neverBlock>false</neverBlock>

  <!-- Only enable if the pattern prints %C, %M, %L or %F. -->
  <includeCallerData>false</includeCallerData>

  <!-- Time allowed to drain the queue on shutdown (default 1000 ms). -->
  <maxFlushTime>5000</maxFlushTime>
</appender>

<root level="INFO">
  <appender-ref ref="ASYNC_FILE"/>
</root>

<!-- Do not wrap the same appender in two async appenders, and do not also list the
     inner appender under root; it would then be written twice (once sync, once async). -->

Filters

Filters decide which events an appender will accept. Each returns ACCEPT, DENY, or NEUTRAL, and they run in order. Use ThresholdFilter for "this level and above" (for example, a console that only shows WARN+), and LevelFilter for one exact level. Put them inside an appender and they affect only that appender. For anything based on MDC values or message content, you have two realistic choices. MDCFilter is a TurboFilter, which means it sits at the top level of the configuration and runs before the event is created, for all appenders, not inside a single one. Be careful with ACCEPT there, because it bypasses the normal level check. For per-appender logic, write a small Java class extending ch.qos.logback.core.filter.Filter, as below. Prefer fixing the source of the noise before filtering it. If health-check lines come from a request-logging filter, lowering that logger's level is cleaner than a custom filter.
XML
<!-- Per-appender: only WARN and above -->
<appender name="CONSOLE_WARN" class="ch.qos.logback.core.ConsoleAppender">
  <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>WARN</level>
  </filter>
  <encoder><pattern>%d{HH:mm:ss.SSS} %-5p %logger{36} : %m%n%wEx</pattern></encoder>
</appender>

<!-- Global TurboFilter (top level of <configuration>, not inside an appender) -->
<turboFilter class="ch.qos.logback.classic.turbo.MDCFilter">
  <MDCKey>debugUser</MDCKey>
  <Value>true</Value>
  <OnMatch>ACCEPT</OnMatch>         <!-- force-log everything for flagged requests -->
</turboFilter>

<!-- Per-appender custom filter -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  <filter class="com.myapp.logging.NoiseFilter"/>
  <encoder><pattern>%d{HH:mm:ss.SSS} %-5p %logger{36} : %m%n%wEx</pattern></encoder>
</appender>

// com/myapp/logging/NoiseFilter.java
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.filter.Filter;
import ch.qos.logback.core.spi.FilterReply;

public class NoiseFilter extends Filter<ILoggingEvent> {

    @Override
    public FilterReply decide(ILoggingEvent event) {
        // getFormattedMessage() has the arguments filled in; getMessage() is the raw template.
        String msg = event.getFormattedMessage();
        if (msg != null && msg.contains("/actuator/health")) {
            return FilterReply.DENY;
        }
        // MDC-based rule: drop DEBUG chatter for one noisy tenant.
        String tenant = event.getMDCPropertyMap().get("tenantId");
        if ("load-test".equals(tenant) && event.getLevel().toInt() < ch.qos.logback.classic.Level.INFO_INT) {
            return FilterReply.DENY;
        }
        return FilterReply.NEUTRAL;
    }
}

MDC and Correlation IDs

The MDC (Mapped Diagnostic Context) attaches key-value pairs to every log line written by the current thread, which is what makes it possible to find all the lines for one request. If you use Micrometer Tracing, traceId and spanId are put into the MDC for you; the pattern %X{traceId:-} just reads them. For your own correlation ID, set it in a servlet filter and always clear it in a finally block, because servlet threads are pooled and a leftover value leaks into the next request. MDC is thread-local. It does not follow work onto another thread, so @Async methods, executor tasks, and reactive pipelines lose it unless you copy it across (a TaskDecorator is the standard fix for executors).
Java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String id = Optional.ofNullable(req.getHeader("X-Correlation-Id"))
                            .filter(s -> !s.isBlank())
                            .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("correlationId", id);
        res.setHeader("X-Correlation-Id", id);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();          // threads are reused; never skip this
        }
    }
}

// Carry MDC onto @Async / executor threads
@Bean
TaskDecorator mdcTaskDecorator() {
    return runnable -> {
        Map<String, String> context = MDC.getCopyOfContextMap();
        return () -> {
            if (context != null) MDC.setContextMap(context);
            try { runnable.run(); } finally { MDC.clear(); }
        };
    };
}