nginx as a Reverse Proxy: A Practical Guide for Java and Spring Boot Services
Most Java backends do not run alone. A typical Spring Boot service sits behind a load balancer, serves HTTPS on behalf of a domain, needs rate limits, and sometimes hosts several microservices under one hostname. Letting every service handle TLS, compression, and connection management on its own is repetitive and fragile. This is where nginx as a reverse proxy earns its place.
In this guide we will cover what a reverse proxy does, how to configure nginx in front of Spring Boot services, how to handle WebSockets and forwarded headers correctly, how to load balance and add basic resilience, and which mistakes to avoid in production.
Why Put a Reverse Proxy in Front of Your Services?
A forward proxy acts on behalf of clients. A reverse proxy acts on behalf of servers: clients talk to one public endpoint, and the proxy forwards each request to the right internal service. The client never needs to know how many instances exist or where they run.
For Java services, nginx typically handles:
- TLS termination: certificates live in one place, and the JVM speaks plain HTTP on a private network.
-
Routing:
/api/ordersgoes to the orders service,/api/usersgoes to the users service. - Load balancing: distributing traffic across several instances of the same service.
- Buffering: nginx absorbs slow clients so that Tomcat threads are not held by a trickling upload.
- Static content and compression: serving assets without touching the application.
- Rate limiting and basic access control: protecting endpoints before requests reach business logic.
The application keeps its focus on business rules, while the edge takes care of protocol concerns.
A Minimal Reverse Proxy Configuration
Assume a Spring Boot service listens on port 8080. The smallest useful configuration looks like this:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Each line matters. proxy_pass forwards the request. The proxy_set_header directives preserve information that would otherwise be lost: the original host name, the client IP address, and whether the original request used HTTPS. Without them, your Spring application logs the proxy's address for every call, and any redirect it generates may point to http:// instead of https://.
Making Spring Boot Trust the Forwarded Headers
Spring Boot does not automatically trust X-Forwarded-* headers, because anyone can send them. You need to tell the framework that a proxy sits in front of it. In application.yml:
server:
forward-headers-strategy: framework
port: 8080
With forward-headers-strategy: framework, Spring's ForwardedHeaderFilter rewrites the request scheme, host, and remote address based on the headers nginx sets. Only enable this when the service is reachable exclusively through the proxy. If clients can reach port 8080 directly, they can spoof these headers.
A quick check in a controller confirms the setup:
@RestController
public class RequestInfoController {
@GetMapping("/debug/request")
public Map<String, String> requestInfo(HttpServletRequest request) {
return Map.of(
"scheme", request.getScheme(),
"remoteAddr", request.getRemoteAddr(),
"host", request.getServerName()
);
}
}
Call it through nginx and you should see https as the scheme and the public host name, not the internal address.
Routing Several Microservices Under One Domain
A common pattern is to expose several services under a single hostname, each with its own path prefix:
upstream orders_service {
server 10.0.1.10:8081;
}
upstream users_service {
server 10.0.1.20:8082;
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location /api/orders/ {
proxy_pass http://orders_service/;
include /etc/nginx/proxy-common.conf;
}
location /api/users/ {
proxy_pass http://users_service/;
include /etc/nginx/proxy-common.conf;
}
}
Notice the trailing slash on each proxy_pass. It tells nginx to strip the /api/orders/ prefix before forwarding, so the Spring service receives / and can keep its own controllers unchanged. If your services already expect the full path, remove the prefix stripping and keep the path as it is.
Moving the shared headers into proxy-common.conf keeps every location consistent:
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
Load Balancing Multiple Instances
When one instance is not enough, define several backends in an upstream block:
upstream orders_service {
least_conn;
server 10.0.1.10:8081 max_fails=3 fail_timeout=10s;
server 10.0.1.11:8081 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8081 backup;
}
Three directives deserve attention:
-
least_connsends each request to the server with the fewest active connections. It suits Java services whose request durations vary widely, because round robin can pile slow requests onto one instance. -
max_failsandfail_timeoutmark a server as unavailable after repeated failures, so nginx stops sending it traffic for a short period. -
backupreceives traffic only when the primary servers are down.
Pair this with a health endpoint. Spring Boot Actuator exposes /actuator/health, which can be used by an external monitor or a commercial nginx setup to check instances before routing traffic to them. Open-source nginx performs passive health checks through max_fails, so keep the failure thresholds realistic.
Supporting WebSockets and Server-Sent Events
Spring applications that push updates to browsers need connection upgrades. nginx only forwards the Upgrade header when you ask it to:
location /ws/ {
proxy_pass http://orders_service/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
For Server-Sent Events, the key is to disable buffering so events reach the client immediately:
location /api/stream/ {
proxy_pass http://orders_service/stream/;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
Without proxy_buffering off, nginx can hold events in its buffer until a threshold is reached, and the client sees nothing for long periods.
Protecting the Backend: Timeouts, Buffers, and Rate Limits
Reverse proxies are also a defensive layer. Two settings protect the JVM:
client_max_body_size 5m;
client_body_timeout 10s;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
location /api/ {
limit_req zone=api_limit burst=40 nodelay;
proxy_pass http://orders_service/;
include /etc/nginx/proxy-common.conf;
}
client_max_body_size rejects oversized uploads before they reach Spring, which keeps heap usage predictable. limit_req applies a token-bucket limit per client IP. A burst of 40 requests is absorbed immediately, and sustained traffic above 20 requests per second receives 503 responses. For fine-grained limits per user or API key, implement the logic in the application or in an API gateway, because nginx sees only IP addresses.
Also make sure timeouts on both sides agree. If nginx gives up after 30 seconds but Tomcat keeps processing for 60, you waste capacity on responses nobody will read. Align proxy_read_timeout with the longest legitimate request your service should handle.
Common Mistakes
Forgetting forward-headers-strategy. Redirects point to http:// and client IPs in logs are wrong. Enable it and verify with a debug endpoint.
Exposing the service port. If 8080 is reachable from the internet, anyone can send spoofed X-Forwarded-For headers. Bind the application to 127.0.0.1 or to a private interface.
Buffering streams. Remember proxy_buffering off for SSE and streaming responses.
Mismatched trailing slashes. A proxy_pass with a trailing slash rewrites the path; one without it does not. Test each location with curl -v before deploying.
Ignoring keep-alive. Without proxy_http_version 1.1 and an empty Connection header, nginx opens a new upstream connection for every request, which adds latency and churns sockets.
Best Practices Checklist
- Terminate TLS at nginx and keep the JVM on a private network.
- Set
forward-headers-strategy: frameworkonly when the service is never reachable directly. - Use
least_connfor uneven request durations. - Enable
proxy_http_version 1.1for upstream keep-alive. - Set
client_max_body_sizeand rate limits at the edge. - Align nginx and application timeouts.
- Log
$request_timeand$upstream_response_timeto separate network delay from application latency. - Test configuration changes with
nginx -tand reload withnginx -s reload, never restart during peak traffic.
Conclusion
nginx as a reverse proxy gives a Java platform a single, well-defined entry point. It handles TLS, routing, load balancing, buffering, and basic protection, so your Spring Boot services can focus on the domain logic they were written for. The configuration is short, but the details matter: forwarded headers, streaming buffers, timeouts, and network exposure decide whether the setup is secure and predictable.
Start with the minimal configuration, verify the forwarded headers, and add routing, load balancing, and rate limits as your architecture grows. Your future on-call self will thank you.
Key takeaways
- A reverse proxy hides topology from clients and centralizes edge concerns.
- Spring Boot must be told to trust forwarded headers, and only when traffic flows through the proxy.
- Use upstream blocks,
least_conn, and health-aware failover for multiple instances. - Disable buffering for streams and align timeouts across layers.
- Restrict the backend to the private network.
Top comments (0)