Server configuration#
server {
host = "" # Address to listen on. Empty means all of them
port = 9000
max_request_size = 10MiB # Limit on the request body
max_header_size = 16KiB # Limit on the headers as a whole
idle_timeout = 60s
trust_proxy = false # Whether to believe X-Forwarded-For / X-Forwarded-Proto
compression = true # Whether to gzip text responses (1KB and up)
access_log = true # Whether to write the access log ([Logging](./log))
bot_access_log = true # Whether to split out the bot access log
access_log_mask = [] # Extra query names whose values the access log hides ([Logging](./log))
strict_routes = false # Throw on routes nothing can reach (turn this on in CI)
shutdown_grace = 0s # From "start stopping" to refusing new requests (seconds)
shutdown_timeout = 15s # How long to wait for in-flight work (seconds)
backlog = 1024 # Connections the OS holds while accept catches up
write_queue_length = 0 # Length of the response write queue; 0/1 means "no queue"
smart_async_writes = false # With a queue, write inline while it is not busy
tcp_no_delay = true # TCP_NODELAY (turn Nagle off; send small writes at once)
}
Accepting connections and writing responses#
backlog is the number of connections the OS holds for you while accept catches up.
Past that, the OS turns callers away — it never reaches the application, so
not a single line appears in your log.
Raise it for bursty traffic (just after a restart, a campaign, a wave of reconnects);
it does not make anything faster. It only lets the backlog wait instead of being refused.
Note
The OS has its own ceiling (somaxconn on Linux).
Raising this does nothing if that one is lower.
write_queue_length is the length of the queue responses are written through.
At 0 or 1 there is no queue and the response is written inline (the default).
At 2 or more, another thread does the writing, so your handler can move on while a slow
client is still being written to. What is queued sits in memory, though — a bigger
queue is not a faster server.
smart_async_writes chooses, when there is a queue, between "always queue" and
"write inline while the queue is idle, switch to queueing when it gets busy".
Careful
smart_async_writes does nothing on its own.
Helidon does not read it unless write_queue_length is 2 or more (there is no queue otherwise).
Leaving write_queue_length at 0 or 1 while setting smart_async_writes = true fails at startup —
silently ignoring it would leave you with a setting that "is on but changes nothing".
tcp_no_delay sets TCP_NODELAY on accepted connections (turns Nagle's algorithm off).
The default is true (Helidon's default is false).
Without it, a response written to outputStream() in several pieces (Transfer-Encoding: chunked:
HTML, SSE and so on) is delayed by about 40 ms per request from the second request on the same connection:
the later small write waits for the ACK of the earlier one (Nagle), and that meets the client's delayed ACK.
nginx reuses upstream connections (keepalive), so this happens behind a proxy too.
Responses written in one go with Content-Length (json() and the like) are not delayed.
In aqSell, turning it on was enough for this (one CPU, one user, reused connections, the checkout flow):
| off | on | |
|---|---|---|
| Median HTML page time (same behind nginx) | 44–51 ms | 3–9 ms |
| Checkouts per minute | 136 | 395 |
Many small responses can mean more packets on the wire, but the delayed-ACK waits disappear.
Set it to false only in the special case where you rely on Nagle.
The startup log says which one was chosen.
サーバー設定(接続): 接続待ち=1024 / 書き出し列=作らない(その場で書く) / TCP_NODELAY=true
サーバー設定(接続): 接続待ち=1024 / 書き出し列=32(空いていればその場で書く) / TCP_NODELAY=false
Write nothing and you get the defaults above. What is actually in effect is printed in the startup log.
jimble を起動しました: http://localhost:9000
サーバー設定: 待受=全部 / 本文上限=10485760byte / ヘッダ上限=16384byte / アイドル=60秒 / 圧縮=true / プロキシ信頼=false
Port#
The port, and only the port, can also be given as a system property. That is so a container can change the port without touching the configuration file.
java -Djimble.server.port=8080 -jar app.jar
The order of precedence is -Djimble.server.port > server.port > 9000.
Tip
JimbleServer.start(app, 0) takes a free port automatically.
Use this in tests (pin the port and they fail only when you run them side by side).
The port it actually took comes back from server.port().
The listen address#
Empty means it listens on every address. Narrow it down for anything that must not be visible from outside.
server { host = "127.0.0.1" }
Tip
An MCP server you run on your own machine needs this (MCP). Lean on the firewall instead, and the day someone forgets to configure it you are silently public.
Limits#
| Limit | When it is exceeded | |
|---|---|---|
| Request body | server.max_request_size (10MiB) |
413 |
| Headers as a whole | server.max_header_size (16KiB) |
the connection is cut |
| A connection doing nothing | server.idle_timeout (60 seconds) |
closed |
Uploads have a separate limit of their own (File upload).
Careful
upload.max_total_size larger than server.max_request_size fails at startup
— the body is cut here first, so the larger limit could never be reached.
Raise both if you are going to accept large things (both default to 10MiB).
There are no read or write timeouts.
Behind a proxy#
By default X-Forwarded-* is not believed. You start believing it once you know for certain that every request comes through the proxy in front.
server {
trust_proxy = true
trusted_proxies = ["10.0.0.0/8"] # IPs / CIDRs of the proxies; when set, headers are read only from them
client_ip_header = "" # "CF-Connecting-IP", "X-Real-IP", ...; trusted only when set
}
With true, proxyAddress() returns the source in this order.
- if
trusted_proxiesis set and the connection source is not in it, the connection source (the headers are just claims) - if
client_ip_headeris set, that header's value X-Forwarded-Forread from the right, the first entry not intrusted_proxies(the last entry when it is not set)- the connection source, if none of those are there
Trap
The leftmost X-Forwarded-For entry is whatever the client claims.
nginx, ALB and jimble's ReverseProxy append to the value the client sent.
Up to 2.2.2 the leftmost entry, CF-Connecting-IP and X-Real-IP were trusted unconditionally,
so changing the claimed value slipped past per-IP rate limits.
With two or more hops (CDN → load balancer and so on), list the proxies in trusted_proxies.
Trap
Setting trust_proxy to true does not change address().
address() is always the TCP connection source (= the load balancer).
Use proxyAddress() anywhere you decide something from the source address.
Trap
Bot detection (isBotAccess()) looks at address().
Behind a proxy, the IP it judges on is the load balancer's.
With true, scheme() / isSecure() also read X-Forwarded-Proto (since 2.5.3).
Even with TLS terminated in front, the app can tell the client came over https (this is also what makes the HSTS header go out).
- With
trusted_proxiesset, it is only read when the connection comes from one of them - With several values, the rightmost one is used (added by the nearest hop); the left ones can be made up by the client
- Values other than
http/httpsare ignored and the connection's scheme is used
Note
Make the proxy in front set X-Forwarded-Proto. For nginx: proxy_set_header X-Forwarded-Proto $scheme;.
ALB, Cloud Run and the like set it for you.
Careful
Do not set true while the server can still be reached directly.
Anyone can attach those headers, so the source address can be forged at will.
There is no mechanism for restricting trusted upstreams by IP (it is a single boolean).
Compression#
server.compression (default true) turns it on / off.
When the client says it accepts it in Accept-Encoding, only text responses (text/*, JSON, JavaScript, XML, SVG) of 1KB or more
are compressed (since 2.5.2).
- Images, video, zip files and downloads are not compressed (they do not shrink, it costs CPU, and
Content-Lengthis lost) - Small responses are not compressed (setting up the compressor costs more than it saves)
- SSE (
text/event-stream) is not compressed (it would be held back and arrive late)
Note
There is no compression level and no setting to add types. Do the fine-grained control in front of jimble.
Blocking bots#
If you only need to tell them apart, splitting the access log is enough (Logging). To block them, declare it.
before(BotBlocker.forbidden()); // return 403
before(BotBlocker.of(context -> context.response().redirect("/"))); // return whatever you like
Note
There is no default "block" behaviour. Blocking the search engines too would hurt, so what to return is left to the app.
What is not here#
| TLS / HTTPS | Terminate it in front (load balancer, nginx) |
| HTTP/2 | Not in the dependencies |
| A health check route | jimble does not provide one (see "Stopping" below) |
Stopping#
It does not stop dead. stop() goes in this order.
- Mark itself as stopping —
Shutdown.isStopping()becomes true. Ordinary requests are still served - Wait
server.shutdown_grace(default 0) - Refuse new requests (503)
- Wait for the in-flight ones to finish, up to
server.shutdown_timeout(default 15 seconds) - Stop the server
On SIGTERM this runs by itself (that is what a container sends, and then it waits).
Take the health check down first#
jimble does not provide a health check route. Write it like this.
get("/health_check", context ->
context.response().send(Shutdown.isStopping() ? 503 : 200));
Tip
It takes the load balancer a while to pull this machine out of rotation.
Answer 503 to the requests that arrive in the meantime and from the outside that is an error.
That is why there is room to wait between step 1 and step 3.
Put in the health check interval × the failure count (2 seconds × 3, say → shutdown_grace = 10s).
Careful
If the wait runs out, it stops with work still in flight.
When that happens, 処理中のリクエストが N 件残ったまま停止します goes out at warn.
Not stopping at all is the worse outcome (the container kills it).
Note
To have threads you started yourself stop along with it, hand them to Shutdown.add(...)
(Execution model).
The running marker (RUNNING_PID_{port})#
Once it is listening, jimble puts a file named like RUNNING_PID_8080 in
the directory that holds the application jar. Its content is the process ID.
It is removed when stop() completes (SIGTERM included).
kill $(cat /opt/app/RUNNING_PID_8080) # stop it
test -f /opt/app/RUNNING_PID_8080 # is it running?
- The port number is part of the name, so several servers started from the same directory do not erase each other's marker
- When not running from a jar (
jimbleRun, tests) nothing is written. The place is "the jar's directory", and there is no jar to sit next to - If a marker from the previous run is still there (
kill -9, power loss), it warns and overwrites. Startup is not blocked - If it cannot be written (read-only directory, for instance) it warns and keeps listening
Making jimble the proxy#
The other way round: jimble can forward on to another server.
install(() -> ReverseProxy.mount("/api", "http://backend:8080"));
X-Forwarded-For is appended to the existing value (not overwritten).
The timeouts are proxy.connect_timeout (5 seconds) and proxy.request_timeout (30 seconds),
and a forward that fails returns 502.
| Limit | |
|---|---|
| Connecting | connect_timeout |
| Sending the request | cut if not a single byte goes out for request_timeout |
| From the end of sending to the response headers | request_timeout in total (since 2.2.4; before that it was per read, so a target trickling bytes never ended) |
| The response body | request_timeout per read. In total, proxy.body_timeout (default 0, no limit) or ReverseProxy#bodyTimeout |
The body has no total limit by default so large downloads and SSE are not cut off midway.
The path is re-encoded segment by segment, and a path containing . or .. gets 400
(so a request cannot climb out of the base URL's path).
The equivalents of nginx's proxy_set_header and friends#
Pass a configured proxy to mount. Finish the configuration before mount (changing it after it has handled a request throws).
install(() -> ReverseProxy.mount("/shop", new ReverseProxy("http://shop:9000")
.preserveHost() // proxy_set_header Host $http_host
.setHeader("X-App", "front") // proxy_set_header X-App front
.setHeader("X-User", context -> userId(context)) // value built from the request (null: not sent)
.removeHeader("Cookie") // proxy_set_header Cookie ""
.hideResponseHeader("X-Powered-By") // proxy_hide_header X-Powered-By
.redirect("http://shop.internal/", "/shop/") // proxy_redirect http://shop.internal/ /shop/
.cookieDomain("shop.internal", "example.com") // proxy_cookie_domain shop.internal example.com
.cookiePath("/", "/shop/"))); // proxy_cookie_path / /shop/
| The same defaults as nginx | |
|---|---|
Host |
The upstream's is sent (proxy_set_header Host $proxy_host). The original Host goes in X-Forwarded-Host / X-Forwarded-Port. If the upstream routes by host, add preserveHost() |
Location / Refresh |
Values starting with the upstream URL are rewritten to the path the browser sees (proxy_redirect default: http://backend:8080/moved → /api/moved). Relative values (/moved) are left alone. Turn it off with noRedirectRewrite() |
| Upstream connection | Closed after each request (no keepalive). For https:// upstreams the certificate's host name is checked |
| Compression | A gzip body from the upstream is passed through as is (not compressed a second time) |