How to Fix 502 Bad Gateway and 504 Gateway Timeout Errors (2026)

Oct 20, 2026 · 15 min · Ajay Kumar

This guide is for anyone staring at a 502 Bad Gateway or 504 Gateway Timeout and wanting the cause rather than a list of things to restart. By the end you will be able to tell which hop generated the error, read the proxy log line that explains it, and fix the five causes I see most: keepalive and idle timeout mismatches, slow upstreams, Kubernetes rollout gaps, nothing listening on the port, and bad upstream TLS or headers. The NGINX log lines below come from my own reproduction on NGINX 1.27.5; configuration references are checked against NGINX 1.30 (current stable), the AWS Application Load Balancer and Google Cloud load balancer docs, Gateway API v1.6 and Kubernetes 1.37. You need shell access to the proxy or cluster and permission to read load balancer logs.

502 vs 504: what each error actually means

Both codes are generated by something sitting in the middle, never by the application that failed. RFC 9110 defines 502 as a gateway or proxy that "received an invalid response" from the server behind it, and 504 as one that "did not receive a timely response". In practice:

  • 502 means the proxy reached for the upstream and got something unusable: connection refused, a reset, a connection closed mid-request, a TLS failure, or garbage instead of HTTP.
  • 504 means the proxy waited and gave up: the connect or the response took longer than its timeout.

So the useful question is "which proxy said this, and what did it see". A request may cross a CDN, a cloud load balancer, an ingress or gateway and a sidecar, each with its own timeouts and logs.

Step 1: Find out which hop generated the error

Headers and the error body usually name the component.

# -s silent, -v show headers, -o discard body; -w prints timing so you can spot a timeout boundary
curl -sv -o /dev/null -w 'status=%{http_code} total=%{time_total}s\n' https://app.example.com/slow-endpoint 2>&1 \
  | grep -Ei '^< (server|via|cf-ray|x-amzn|x-cache)|status='

Things to look for:

  • server: cloudflare plus a 52x code: Cloudflare generated it. A plain 502 or 504 through Cloudflare may be your origin's, so check the body.
  • server: awselb/2.0: the AWS load balancer. Almost exactly 60 seconds is the ALB default idle timeout.
  • via: 1.1 google or a Google error page: the Google Cloud load balancer. A 30-second 504 matches its default backend service timeout.
  • server: nginx or envoy: your ingress, gateway or sidecar.

A time_total that lands on a round number (30s, 60s, 125s) is a timeout, and the number tells you whose.

502 or 504 at the client Step 1: which hop generated it? curl -sv, Server header, LB access logs CDN (Cloudflare 52x) origin refused, slow or bad TLS LB or proxy generated it read its error log (Step 2) Backend sent the 5xx app bug: read application logs refused, reset, closed Causes 1, 3, 4, 5 (mostly 502) timed out Cause 2: slow upstream (504)
Decision flow used in this guide. The branch labels match the cause sections below; classification of 502 versus 504 follows RFC 9110 and the AWS and Google Cloud load balancer troubleshooting docs.

On AWS, the ALB access log separates the two cleanly. Field 9 is elb_status_code and field 10 is target_status_code; per the access log reference, target_status_code is - when no response came back from the target, and target_processing_time is -1 when the target closed the connection or did not respond before the idle timeout.

# Count ALB-generated 502/504s where the target never answered, from a downloaded log file.
zcat 123456789012_elasticloadbalancing_*.log.gz \
  | awk '($9==502 || $9==504) && $10=="-" {print $9, $7, $5}' | sort | uniq -c | sort -rn | head
# Output columns: count, elb_status_code, target_processing_time, target:port

On Google Cloud, the load balancer log entry's statusDetails names the reason, for example backend_connection_closed_before_data_sent_to_client, failed_to_connect_to_backend or backend_timeout, and response_sent_by_backend means your app produced the 5xx itself (troubleshooting guide). Note that the classic Application Load Balancer uses 502 for every error it generates, timeouts included.

Step 2: Read the NGINX upstream error log

If NGINX (or anything built on it) generated the error, its error log has the answer. I reproduced both codes locally with one upstream that was not running and one that slept for five seconds behind proxy_read_timeout 2s:

[error] 47745#0: *1 kevent() reported that connect() failed (61: Connection refused) while connecting to upstream, client: 127.0.0.1, server: , request: "GET /dead HTTP/1.1", upstream: "http://127.0.0.1:9001/dead", host: "127.0.0.1:8088"
[error] 47745#0: *3 upstream timed out (60: Operation timed out) while reading response header from upstream, client: 127.0.0.1, server: , request: "GET /slow HTTP/1.1", upstream: "http://127.0.0.1:9002/slow", host: "127.0.0.1:8088"

The first returned 502, the second 504 after 2.0 seconds. On Linux the same lines read connect() failed (111: Connection refused) and upstream timed out (110: Connection timed out). The messages map to causes like this:

Error log message Status Cause section
connect() failed (111: Connection refused) while connecting to upstream 502 Cause 4: nothing listening
upstream prematurely closed connection while reading response header 502 Cause 1 (keepalive) or app crash mid-request
recv() failed (104: Connection reset by peer) 502 Cause 1 or Cause 3 (pod terminating)
upstream timed out (110: Connection timed out) while reading response header 504 Cause 2: slow upstream
upstream timed out (110: ...) while connecting to upstream 504 Network policy, security group or dead node
upstream sent too big header while reading response header 502 Cause 5: raise proxy_buffer_size
SSL_do_handshake() failed 502 Cause 5: upstream TLS
# Find the dominant upstream error in the last hour (Linux, NGINX default log path)
sudo awk -v d="$(date -d '1 hour ago' '+%Y/%m/%d %H')" '$0 >= d' /var/log/nginx/error.log \
  | grep -oE '(connect\(\) failed|upstream timed out|prematurely closed|reset by peer|too big header|no live upstreams)[^,]*' \
  | sort | uniq -c | sort -rn

In Kubernetes, the equivalent is kubectl logs -n <ingress-namespace> deploy/<controller> | grep upstream.

Cause 1: Keepalive and idle timeout mismatch (intermittent 502s)

The classic: a low trickle of 502s that never reproduces by hand. The proxy reuses an idle keepalive connection at the moment the backend closes it for being idle, the request lands on a dying socket, and the proxy reports a 502.

AWS documents it directly: a 502 happens when "the target closed the connection with a TCP RST or a TCP FIN while the load balancer had an outstanding request", and the fix is to make the target's keepalive longer than the load balancer's idle timeout (ALB troubleshooting). The ALB idle timeout defaults to 60 seconds, configurable from 1 to 4,000 (attributes). Google Cloud's backend keepalive is fixed at 600 seconds, and Google recommends 620 seconds on the backend web server.

Compare application server defaults. Node.js server.keepAliveTimeout is 5,000 ms (I checked: node -e "console.log(require('http').createServer().keepAliveTimeout)" prints 5000 on Node 20). Gunicorn's keepalive default is 2 seconds, and its own config docs say to set it higher behind a load balancer. Both are well below 60.

Check the load balancer side first:

aws elbv2 describe-load-balancer-attributes \
  --load-balancer-arn "$ALB_ARN" \
  --query "Attributes[?Key=='idle_timeout.timeout_seconds'].Value" --output text
# 60

Then raise the backend keepalive above it. Node.js:

// server.js: keep idle sockets open longer than the ALB idle timeout (60s)
const http = require('node:http');
const server = http.createServer(app);
server.keepAliveTimeout = 65_000;  // must exceed the load balancer idle timeout
server.headersTimeout = 66_000;    // keep this above keepAliveTimeout
server.listen(8080);

Gunicorn (with a worker class that supports keepalive; the sync worker ignores it):

gunicorn app:app --worker-class gthread --threads 4 --keep-alive 65 --timeout 60 --bind 0.0.0.0:8080

NGINX as the backend behind an ALB:

# http {} or server {} block of the NGINX that the ALB talks to
keepalive_timeout 65s;   # client-side keepalive, must exceed the ALB's 60s idle timeout

On Google Cloud, use 620 seconds instead of 65. The rule: each backend's keepalive must outlast the idle timeout of the hop in front of it.

NGINX as the proxy also keeps connections to its upstreams. Since NGINX 1.29.7, upstream keepalive is on by default (keepalive 32 local, HTTP/1.1, no Connection header), so on 1.30 you no longer need the old proxy_http_version 1.1 and proxy_set_header Connection "" lines (upstream module). The upstream keepalive_timeout defaults to 60 seconds, so make sure your app's keepalive is longer than that too, or lower NGINX's.

Cause 2: The upstream is too slow (504 Gateway Timeout)

A 504 that arrives at a fixed duration is a timeout firing somewhere. The defaults worth memorising:

Hop Timeout Default Source
NGINX proxy_read_timeout between two reads from upstream 60s NGINX proxy module
NGINX proxy_connect_timeout TCP connect to upstream 60s (usually capped near 75s) NGINX proxy module
AWS ALB idle timeout no data in either direction 60s; connect timeout 10s ALB docs
Google Cloud backend service timeout first request byte to last response byte 30s for most backends GCP troubleshooting
Cloudflare proxy read timeout (524) origin response 125s; Enterprise up to 6,000s Cloudflare 524

The real fix is rarely "raise every timeout". If an endpoint takes minutes, make it asynchronous: return 202 with a job ID and let the client poll, which is also Cloudflare's advice for long exports. When a longer timeout is justified, raise it at every hop, outer hops slightly longer than inner ones, so the innermost layer fails first and logs a useful error. For NGINX:

location /reports/ {
    proxy_pass http://reports_backend;
    proxy_connect_timeout 5s;     # fail fast if the backend is unreachable
    proxy_read_timeout   120s;    # allow slow report generation
    proxy_send_timeout   120s;
}

For Gateway API, timeouts on an HTTPRoute rule has been in the standard channel since v1.2. backendRequest must not exceed request, and support depends on your implementation:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: reports
spec:
  parentRefs:
    - name: public-gateway
  rules:
    - matches:
        - path: { type: PathPrefix, value: /reports }
      timeouts:
        request: 130s          # whole client transaction, including retries
        backendRequest: 120s   # a single attempt to the backend
      backendRefs:
        - name: reports
          port: 8080

If you are still on ingress-nginx, the equivalents are the nginx.ingress.kubernetes.io/proxy-read-timeout and proxy-send-timeout annotations (values in seconds, as strings). Be aware the project was retired in March 2026 and receives no security fixes; see Ingress NGINX Is Retired and the migration guide.

Then raise the cloud layer to match:

# AWS ALB: idle timeout above the NGINX/Gateway timeout
aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
  --attributes Key=idle_timeout.timeout_seconds,Value=135

# Google Cloud: backend service timeout (global external Application Load Balancer)
gcloud compute backend-services update reports-backend --global --timeout=135

Cause 3: 502s during Kubernetes deployments and scale-down

If 502s cluster around rollouts, scale-ins or node drains, the cause is the window between a pod being told to stop and every load balancer forgetting it. Endpoint removal and SIGTERM happen in parallel, so traffic can briefly reach a process that is shutting down; Google's docs call this out for NEG backends. Three settings close the gap: a readiness probe, a preStop sleep so the pod keeps serving while endpoints propagate, and a grace period covering both. The sleep handler is on by default since Kubernetes 1.30, so no shell is needed in the image.

# Deployment pod template excerpt (Kubernetes 1.30 or later)
spec:
  terminationGracePeriodSeconds: 45     # must cover preStop sleep + in-flight requests
  containers:
    - name: web
      image: registry.example.com/web:1.8.2
      ports:
        - containerPort: 8080
      readinessProbe:
        httpGet: { path: /healthz/ready, port: 8080 }
        periodSeconds: 5
        failureThreshold: 2
      lifecycle:
        preStop:
          sleep:
            seconds: 15                 # keep serving while LBs and kube-proxy drop the endpoint

With the AWS Load Balancer Controller, keep the target group's deregistration delay shorter than the grace period, for example with the annotation alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30. Check rollout timing against your errors:

kubectl rollout history deploy/web
kubectl get events --sort-by=.lastTimestamp | grep -E 'Killing|Unhealthy|Readiness'

If pods are crashing rather than rolling, that is a different problem: see How to Fix Kubernetes CrashLoopBackOff.

Cause 4: Nothing is listening (connection refused)

A steady 100% 502 with Connection refused means nobody answered at that address: the Service targetPort is wrong, the app binds to 127.0.0.1, the app crashed, or no pod is ready.

# Does the Service have ready endpoints?
kubectl get endpointslices -l kubernetes.io/service-name=web -o wide

# Is the app listening, and on which address?
kubectl exec deploy/web -- sh -c 'ss -ltnp 2>/dev/null || netstat -ltnp'
# 127.0.0.1:8080 here is the bug: the pod IP cannot reach it. You want 0.0.0.0:8080 or *:8080.

# Bypass every proxy and hit the pod directly
kubectl port-forward deploy/web 8080:8080 &
curl -sv http://127.0.0.1:8080/healthz/ready

A connect timeout rather than a refusal (504 while connecting to upstream) usually means a security group, network ACL or NetworkPolicy dropping packets. If the proxy resolves upstreams by name, see CoreDNS troubleshooting.

Cause 5: Upstream TLS failures and malformed responses

A certificate or protocol mismatch to an HTTPS backend shows up as a 502. NGINX does not send SNI to upstreams unless asked, which breaks backends hosting several names:

location / {
    proxy_pass https://api.internal.example.com;
    proxy_ssl_server_name on;                 # send SNI to the upstream
    proxy_ssl_name api.internal.example.com;
    proxy_buffer_size 16k;                    # default is one memory page (4k or 8k); large cookies or JWT headers overflow it
}

The ALB returns 502 for a target SSL handshake error or response headers over 32 KB; Cloudflare returns 520 for headers over 128 KB. Large Set-Cookie headers from auth libraries are a common trigger.

Cloudflare 520 to 526: what each one points to

Cloudflare's own codes separate its errors from your origin's (5xx index): 520 is an empty or unexpected origin response, 521 a server that is down or refusing Cloudflare, 522 a TCP connection timeout, 523 an unreachable origin, 524 no response within the 125-second read timeout, 525 a failed SSL handshake and 526 an invalid origin certificate. Test the origin directly with curl --resolve app.example.com:443:<origin-ip> https://app.example.com/.

Troubleshooting

Symptom Likely cause Fix
Rare, random 502s under steady load, never reproducible by hand Backend keepalive shorter than LB idle timeout Set app keepalive above LB idle timeout (65s for ALB, 620s for GCP)
504 at exactly 60 seconds NGINX proxy_read_timeout or ALB idle timeout Make the endpoint async, or raise timeouts at every hop
504 at exactly 30 seconds on Google Cloud Backend service timeout default gcloud compute backend-services update ... --timeout=
Cloudflare 524 after about 125 seconds Origin slower than Cloudflare's read timeout Async job and polling, DNS-only subdomain, or Enterprise timeout
502 spike during every deploy Pods receive traffic while terminating preStop sleep, readiness probe, longer terminationGracePeriodSeconds
100% 502, Connection refused in log Wrong port, app bound to 127.0.0.1, or no ready pods Check EndpointSlices, ss -ltnp, fix targetPort or bind address
504 with while connecting to upstream Packets dropped by security group, NACL or NetworkPolicy Allow LB or ingress to target port; test with nc -vz
502 with upstream sent too big header Large cookies or auth headers Raise proxy_buffer_size and proxy_buffers
502 only for HTTPS backends Missing SNI or untrusted upstream cert proxy_ssl_server_name on, fix the backend certificate
Cloudflare 521 while origin works directly Origin firewall blocking Cloudflare IPs Allowlist Cloudflare ranges or use a tunnel

FAQ

What is the difference between 502 Bad Gateway and 504 Gateway Timeout?

Both come from a proxy or load balancer, not your application. A 502 means the proxy got an invalid response or none at all, such as a refused or reset connection. A 504 means it waited for the upstream and its timeout expired first.

Why do I get intermittent 502 errors behind an AWS ALB?

Most often the application closes idle keepalive connections sooner than the ALB's 60-second idle timeout, and the ALB sends a request down a connection the target is closing. AWS recommends setting the target's keepalive timeout higher than the load balancer idle timeout. Node.js defaults to 5 seconds and Gunicorn to 2, so both need raising.

Should I just increase proxy_read_timeout to fix a 504?

Only if the slow response is legitimate and bounded. Raise it at every hop with the outermost slightly longer, and prefer making long operations asynchronous. A higher timeout on an overloaded backend only makes users wait longer for the same error.

Can a 502 come from my application?

The 502 itself is generated by a proxy, but the cause is often the app: a crash mid-request, a closed connection, malformed headers or the wrong listen address. If your load balancer logs show the backend's own status code (for example response_sent_by_backend on Google Cloud), the app returned the 5xx and you should read application logs.

When I get paged for 502s, I first check whether the response time matches a known timeout, then whether the errors line up with deploys; that settles most cases before I open a log. In production I keep every backend's keepalive above the load balancer in front of it, put a preStop sleep on every Deployment behind a load balancer, and alert on the load balancer's own 5xx metric separately from the application's (more on that split in Observability in 2026). Official references: NGINX proxy module, ALB troubleshooting, Google Cloud load balancer troubleshooting, Cloudflare 5xx errors and Kubernetes container lifecycle hooks. If you are building these skills from the ground up, my DevOps roadmap for 2026 explains why this kind of troubleshooting is the part AI has not replaced.

Related: Observability in 2026, Ingress NGINX Is Retired and microVM Production Incidents: 4 Postmortems From My Fleet.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related