Configuring the Spring Boot Server, GZip and HTTP/2
Spring Boot 13 min read
Swapping Tomcat for Jetty or Undertow, the compression setting that does nothing until you also raise the minimum size, why HTTP/2 needs TLS to appear at all, and the cache headers that belong on a fingerprinted asset.
Most of Spring Boot’s server configuration is a property away, and the properties interact in ways that make a setting look broken when it is merely inert. Compression that never compresses, HTTP/2 that stays on 1.1, a cache header on a file that should never be cached. Each is one missing companion setting.
Written against Spring Boot 3.2 and Java 17.
Changing the embedded server
Tomcat is the default. Swapping it is an exclusion plus a starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
spring-boot-starter-undertow in place of the Jetty line for Undertow. Forgetting the exclusion is
the usual failure and it is loud: two servlet containers on the classpath and the context refuses to
start.
Which to pick, honestly: for most applications it does not matter, and Tomcat is the one with the most operational knowledge behind it. Undertow has historically used less memory per connection, Jetty integrates well where you already run Jetty. Choosing on benchmarks is choosing on someone else’s workload.
Port, context path and connections
server.port=8081
server.servlet.context-path=/api
server.tomcat.threads.max=200
server.tomcat.accept-count=100
server.tomcat.connection-timeout=20s
server.port=0 binds a random free port, which is what makes parallel integration tests possible;
@LocalServerPort injects the one that was chosen.
threads.max is the ceiling on concurrent blocking requests. Raising it does not increase throughput
for work that is waiting on a database, the connection pool is the real limit, and a thread pool
larger than the pool it queues against just moves the queue.
accept-count is the backlog of connections the operating system holds once every thread is busy.
Past that, new connections are refused rather than queued, which a client sees as a connection reset
rather than a slow response, a large backlog is not a kindness: it converts a fast failure into a
long wait, and the request usually times out at the caller anyway.
On Java 21, virtual threads change the calculation rather than the settings:
spring.threads.virtual.enabled=true
Each request gets a virtual thread, so the thread ceiling stops being the limit and the connection
pool is exposed as the real one. Worth enabling only after checking that no hot path holds a
synchronized block across blocking I/O, which pins the carrier thread and gives back the problem
you were removing.
GZip compression, and the two settings that make it work
server.compression.enabled=true
server.compression.mime-types=text/html,text/css,application/javascript,application/json,application/xml
server.compression.min-response-size=1KB
enabled=true alone compresses almost nothing, for two reasons. The default MIME type list does not
include application/json, so an API compresses none of its responses. And the default minimum size
is 2 KB, below which compression costs more than it saves, which is correct, but means a test
against a small payload shows no Content-Encoding and reads as a broken setting.
Verify rather than assume:
curl -s -H 'Accept-Encoding: gzip' -o /dev/null -D - http://localhost:8080/api/articles
Look for content-encoding: gzip in the response headers. If it is absent, the response was below
the minimum, its content type was not on the list, or the client did not advertise support.
Two things not to compress: anything already compressed (images, video, zip archives) gains nothing and costs CPU, and, historically, compressing a response that mixes secrets with attacker-controlled input enables the BREACH class of attack, which is why compression on authenticated HTML is a decision rather than a default.
Behind a reverse proxy, decide which layer compresses. Both is waste, neither is common.
HTTP/2 needs TLS
server.http2.enabled=true
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=${KEYSTORE_PASSWORD}
server.ssl.key-store-type=PKCS12
server.http2.enabled=true on its own does nothing over plain HTTP. Browsers only negotiate HTTP/2
over TLS, using ALPN during the handshake, so without a certificate the connection stays on 1.1 and
the property is silently ignored.
On Java 17 with Tomcat, ALPN is built in and no extra dependency is needed: a real difference from the Java 8 era, where it required a boot classpath agent.
In practice the TLS termination is usually at a load balancer or ingress, which speaks HTTP/2 to the browser and HTTP/1.1 to the application. In that arrangement the property on the application is irrelevant, and enabling HTTP/2 is a change to the proxy configuration.
Check what was actually negotiated:
curl -sI --http2 https://localhost:8443/api/articles | head -1
HTTP/2 200 confirms it; HTTP/1.1 200 means the negotiation did not happen.
Caching static resources
spring.web.resources.cache.cachecontrol.max-age=365d
spring.web.resources.cache.cachecontrol.cache-public=true
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
The last two are what make the first safe. The content strategy appends a hash of the file to its
name: main-8f2c1a.css — so a changed file has a different URL. Without it, a one-year max-age on
/css/main.css means browsers hold a stale stylesheet for a year and there is no way to reach them.
With fingerprinting, @{/css/main.css} in a Thymeleaf template resolves to the hashed name
automatically, which is one reason to use the link expression rather than a literal path, the
Thymeleaf walkthrough covers the syntax.
Never apply a long max-age to index.html or any HTML entry point. That file is the thing that
references the fingerprinted assets, so caching it defeats the mechanism.
Multipart uploads
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=12MB
spring.servlet.multipart.file-size-threshold=2KB
spring.servlet.multipart.location=/tmp/uploads
Both size limits default to 1 MB and both matter: the first caps one part, the second the whole body.
file-size-threshold is the size above which a part is spooled to disk rather than held in memory,
and location is where. The file upload
walkthrough covers the handling code.
Request and response size limits
server.max-http-request-header-size=16KB
server.tomcat.max-swallow-size=2MB
server.tomcat.max-http-form-post-size=2MB
The header size is the one that bites in the wild: a large JWT in an Authorization header, or a
long cookie set by an analytics script, exceeds the 8 KB default and the client gets a 400 with no
application involvement and no log entry.
max-swallow-size decides how much of a rejected upload the server reads before closing. Set it too
low and a browser reports a connection reset instead of the 413 your handler produced.
Where a property can come from
All of the above are ordinary properties, and Spring Boot resolves them from a fixed order of sources. From lowest precedence to highest, the ones that matter in practice:
application.propertiesorapplication.ymlpackaged in the jar- the same file outside the jar, in
./config/or the working directory - profile-specific files,
application-prod.properties - environment variables
- command-line arguments
Relaxed binding maps between the naming conventions, so SERVER_COMPRESSION_ENABLED=true as an
environment variable sets server.compression.enabled. That is the mechanism every managed platform
relies on, and it means a container image needs no configuration file at all.
When a setting appears to have no effect, the fastest check is to ask the application what it thinks the value is rather than to reread the file:
management.endpoints.web.exposure.include=env,configprops
/actuator/configprops prints the bound values with their sources, which resolves the “is my
property being read” question in one request. Note it also prints configuration that may be
sensitive, so expose it deliberately rather than in production by default.
Graceful shutdown
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
On SIGTERM the server stops accepting new connections and finishes the ones in flight. Without it,
every rolling deployment drops the requests that were mid-flight: invisible in aggregate and
irritating for whoever was making them. Any orchestrator that replaces instances, from Kubernetes to
Beanstalk, is a reason to set it.
More configuration in the type-safe binding walkthrough and the Spring Boot guides.
Frequently asked questions
How do I replace Tomcat with Jetty or Undertow?
Exclude spring-boot-starter-tomcat from
spring-boot-starter-web and add the Jetty or Undertow starter. Two containers on the classpath
prevents the context from starting.
Why is compression not working?
Two likely causes: the response was under the 2 KB default
minimum, or its content type is not on the list, application/json is not included by default.
Is compressing JSON worth it?
For anything above a few kilobytes, yes; JSON compresses well. Below the minimum size the header overhead exceeds the saving, which is why the floor exists.
Why is HTTP/2 not being used?
Browsers only negotiate it over TLS. Without a certificate the property is ignored. If TLS terminates at a proxy, HTTP/2 is that proxy’s setting rather than the application’s.
Do I need an extra dependency for ALPN?
Not on Java 9 or later. It is part of the JDK. That requirement belonged to Java 8.
How do I change the port for tests only?
server.port=0 binds a random free port and
@LocalServerPort injects it, which is what lets integration tests run in parallel.
What does the content version strategy do?
It appends a hash of each static file to its name, so
a changed file gets a new URL. That is what makes a one-year max-age safe.
Should index.html have a long max-age?
No. It references the fingerprinted assets, so caching it prevents anyone from ever seeing the new ones.
Why does a request fail with 400 and nothing in the log?
Most often an oversized header: a large JWT or cookie past the 8 KB default. The container rejects it before the application sees the request.
What does server.shutdown=graceful change?
On SIGTERM the server stops accepting connections
and completes in-flight requests before exiting, instead of dropping them. Set it anywhere instances
are replaced during a deployment.