Nginx tuning
The shipped contrib/putfs.nginx.conf and its snippets are tuned for blob storage. This page explains the directives and the extras for high concurrency.
Static file serving
sendfile on: files go from the file descriptor to the socket in the kernel, bypassing userspace. The single most important directive.tcp_nopush on: batches the headers and the first chunk into one packet (TCP_CORK).tcp_nodelay on: sends the final chunk immediately instead of waiting to fill a segment. Withtcp_nopushthat is batched headers and no trailing delay.
Connection handling
keepalive_timeout 65: clients that list and then download reuse the connection.keepalive_requests 1000: requests per connection before a reconnect (the default; explicit is defensive).reset_timedout_connection on: RST instead of FIN for timed-out connections, freeing resources without aTIME_WAIT.
Upload handling
client_max_body_size 0: no upload limit. Enforce quotas at the filesystem (zfs set quota) instead.proxy_request_buffering off: streams the body to the API instead of spooling it to a temp file first, which doubles disk I/O. Auth runs before the body is read, so unauthenticated uploads are rejected at the header stage.
Logging
access_log off: a log line per request is blocking I/O that dominates latency. In our benchmarksaccess_log /dev/stdoutcut throughput from 104K req/s to 3.8K req/s. If you need logs, buffer them:access_log /var/log/nginx/access.log buffer=64k flush=5s;.log_not_found off: withtry_files $uri =404, missing files are normal operation and would flood the error log.
High-concurrency settings
For thousands of concurrent connections, at the top level:
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
multi_accept on;
}
worker_rlimit_nofile 65535: every connection and every open file costs a descriptor; the default ~1024 is too low. Needs a matching hostulimit -n.worker_connections 4096: per worker, socores × 4096in total. Default 1024.multi_accept on: accepts all pending connections per event loop iteration.
Note
use epoll is unnecessary on Linux; nginx uses epoll by default.
Async I/O for large files
aio threads: large reads go to a thread pool instead of blocking the worker and every other connection on it. Needs nginx built--with-threads(standard on distros).directio 8m: files of 8MB and more bypass the page cache and useaio threads; smaller files keep thesendfilefast path.directiodisablessendfilefor affected requests itself; there is no conflict.
Do not use directio on ZFS
nginx's directio reads with O_DIRECT at 512 B (4 K on XFS) alignment. OpenZFS < 2.3 has no O_DIRECT and every read fails with EINVAL; OpenZFS ≥ 2.3 (release notes) needs PAGE_SIZE-aligned reads, and range requests at arbitrary offsets are routinely misaligned. The error log shows [crit] pread() ... failed (22: Invalid argument) and the client sees a 502 mid-stream.
On ZFS, drop directio and rely on sendfile + ARC: the ARC already caches data (compressed, with compression=zstd), so bypassing the page cache buys nothing.
location / {
sendfile on;
sendfile_max_chunk 2m; # fairer scheduling between large-file streams
aio threads; # offloads blocking reads to a thread pool
# directio ... # OMIT on ZFS
}
aio threads works without directio. The same applies on FreeBSD (forum thread).
File descriptor cache
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
Caches nginx's own stat() and open() results (descriptors, sizes, mtimes), not content. For a write-once, read-a-few-times workload the kernel's dentry/inode cache and the ZFS ARC already cover this; enable it only if strace -c or perf top show high stat()/open() counts under load.
Warning
Cached metadata can serve a stale Content-Length or Last-Modified for up to open_file_cache_valid after a file changes. Fine for WORM, surprising otherwise.
Gzip compression
gzip on;
gzip_types text/plain application/json text/css application/javascript;
gzip_min_length 1024;
gzip_proxied any;
Blobs don't compress further, but listings (plain-text keys) do, especially for large buckets. gzip_types skips binary content, gzip_min_length skips tiny payloads, gzip_proxied any includes the proxied API responses.
Note
With ZFS compression=zstd, data is decompressed on read and gzip re-compresses the text for the wire: two cycles, but the disk read stays small and the download too.
Upstream keepalive
Without a pool nginx opens a TCP connection per proxied request. Size keepalive to your API concurrency. Proxied requests then need HTTP/1.1:
Auth
Authentication and authorization are two map lookups folded into one $deny gate, resolved from nginx memory with no I/O: if ($deny) { return 403; } at server level. auth_request (a subrequest per request) is the alternative for dynamic auth such as OAuth or token validation. See Auth.
if is not evil here
The nginx wiki warns against if inside location. A server-level if with a bare return is explicitly safe; return does not interact with content handlers.
Timeouts
The 60s defaults time out multi-GB uploads on slow links and long listings.
Unix socket
A Unix socket skips TCP loopback: no port allocation, no handshake, no TIME_WAIT.
granian --interface asgi --uds /run/putfs/putfs.sock \
--workers 4 --runtime-mode mt --loop uvloop \
--backpressure 16 --http 1 --no-ws \
putfs.api:app
In docker-compose the socket lives on a volume shared between the api and nginx containers, as the shipped docker-compose.yml does (putfs_run).
Further reading
ngx_http_core_module:sendfile,tcp_nopush,tcp_nodelay,aio,directio,open_file_cache,keepalive_timeout,client_max_body_sizengx_http_proxy_module:proxy_request_buffering,proxy_http_versionngx_http_upstream_module:keepalive- nginx performance tuning gist