Troubleshooting

503 Service Unavailable on your server: find who refused and why

A 503 means your server is up but refusing work: backend down, PHP-FPM out of workers, a rate limit or maintenance mode. Find the source and add Retry-After.

Published ~9 min read

Short answer: 503 Service Unavailable means the server is up but is declining the request for now: the backend behind it is down, every worker is busy, a rate limit kicked in, or the site is in maintenance mode. On your own server one specific component sends that 503, whether that is nginx, Apache, HAProxy, Varnish or the application itself. Identify which one first: the fix for an exhausted PHP-FPM pool has nothing in common with the fix for a forgotten .maintenance file.

Linux · macOS · who sent the 503?
# Status line, Server header and Retry-After, as the visitor gets them
curl -sI https://example.com/ | grep -i -E '^(HTTP|server|retry-after|via|x-varnish)'

# The same request straight to the origin, skipping Cloudflare or a CDN
curl -skI --resolve example.com:443:203.0.113.10 https://example.com/
Debian · Ubuntu · on the server
# nginx rate limiting (limit_req / limit_conn)
sudo grep -E 'limiting (requests|connections)' /var/log/nginx/error.log | tail -n 5

# Apache could not reach PHP-FPM or a proxied app
sudo grep -E 'AH01079|AH01114|AH02454' /var/log/apache2/error.log | tail -n 5

# PHP-FPM ran out of workers
sudo grep 'max_children' /var/log/php8.3-fpm.log | tail -n 5

Replace example.com with your domain and 203.0.113.10 with the origin’s IP address. The log paths are the Debian and Ubuntu defaults.

From outside your network, the HTTP header checker shows the same status line with the Server and Retry-After headers, and says when a CDN’s bot protection answered with its own 503 instead of your server.

Which component sent the 503?

Each server software has its own default 503 page and log line. Match what you see:

ComponentWhat the 503 looks likeUsual cause
nginxTitle 503 Service Temporarily Unavailable; log line limiting requests or limiting connectionslimit_req / limit_conn (both default to 503), or a return 503 in your config
ApacheThe server is temporarily unable to service your request due to maintenance downtime or capacity problems; AH01079 or AH01114 in the logPHP-FPM or the proxied app is down or its queue is full
HAProxyNo server is available to handle this request.Every server in the backend failed its health check
VarnishError 503 Backend fetch failed, with a Guru Meditation XIDBackend unreachable or too slow
Caddy, TraefikPlain 503; Caddy logs no upstreams available, Traefik answers no available serverHealth checks marked every upstream down
WordPressBriefly unavailable for scheduled maintenance. Check back in a minute.A .maintenance file left by an update
Laravel503 Service Unavailable pagephp artisan down was never undone
CloudflareError page whose source mentions cloudflareCloudflare or Workers side; the origin may be fine

nginx itself almost never answers 503 because a backend is down: a dead upstream gives 502 Bad Gateway and a slow one 504 Gateway Timeout. So a 503 from nginx points at rate limiting or a deliberate return 503 almost every time. Apache is the opposite: mod_proxy_fcgi and mod_proxy_http answer 503 whenever they cannot connect to the backend.

Cause 1: the backend is down

Behind Apache, HAProxy or Varnish, a stopped PHP-FPM, a crashed Node or Python process or an exited container becomes a 503. Caddy and Traefik answer 502 when a backend refuses the connection, and 503 only once health checks have marked every upstream down. Find the service and look at why it stopped:

Debian · Ubuntu
sudo systemctl status php8.3-fpm --no-pager      # 8.2 on Debian 12, 8.4 on Debian 13
sudo systemctl status myapp --no-pager
sudo journalctl -u myapp -n 50 --no-pager

# Does the socket or port the proxy expects exist?
ls -l /run/php/
sudo ss -tlnp | grep -E ':(3000|8000|9000)\s'

# Was it killed for memory?
sudo journalctl -k | grep -i -E 'out of memory|oom-kill' | tail -n 5
  • Socket path changed: after a PHP upgrade the socket becomes php8.4-fpm.sock while the Apache or nginx config still names the old one. Update the handler; on Apache, switch confs with sudo a2disconf php8.3-fpm && sudo a2enconf php8.4-fpm and reload.
  • Apache keeps answering 503 after you fixed it: in a load-balanced setup (BalancerMember), mod_proxy puts a member it could not reach into an error state and skips it for the retry period, 60 seconds by default. The log shows AH00959: ap_proxy_connect_backend disabling worker. Wait a minute before concluding the fix did not work. A single backend behind a plain ProxyPass or SetHandler is tried again on the next request.
  • It keeps dying: if the OOM killer is in the kernel log, the server is out of memory, and adding workers (cause 2) will make it worse.

Cause 2: the PHP-FPM pool is exhausted (pm.max_children)

PHP-FPM runs a fixed maximum of worker processes, pm.max_children, and the stock pool file sets it to just 5. Each worker handles one request at a time. When all are busy, new requests wait in the socket’s queue. Once that queue is full or the wait too long, the web server gives up: Apache answers 503 (504 once its timeout runs out), nginx 502 or 504. The PHP-FPM log says it first:

/var/log/php8.3-fpm.log
WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

With pm = ondemand the same warning reads server reached max_children setting, without the pm., which is why the grep above searches for max_children alone. FPM writes it once each time the pool hits its ceiling, so count the lines per day to see how often you run out of workers.

Do not just multiply the number. Size it from memory, because too many workers make the server swap or trigger the OOM killer, which is worse than a short queue:

Debian · Ubuntu · average memory per worker
ps --no-headers -o rss -C php-fpm8.3 | awk '{ sum += $1 } END { printf "%d processes, %.0f MB average\n", NR, sum / NR / 1024 }'
free -m

Then: (RAM you can give PHP) ÷ (average worker size) = pm.max_children. On a 4 GB VPS where the database and the OS need about 1.5 GB, 2.5 GB is left for PHP; at 60 MB a worker that is about 40. Measure under real traffic, since an idle worker is smaller than a busy one.

/etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests = 500            ; recycle workers to contain memory leaks

; log every request that takes longer than 5 s, with a PHP stack trace
request_slowlog_timeout = 5s
slowlog = /var/log/php8.3-fpm.slow.log
Debian · Ubuntu
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
sudo tail -f /var/log/php8.3-fpm.slow.log

The slow log is the real cure. Workers are rarely short because of traffic alone; they are short because a few requests hang on a slow query, an external API or a lock and hold their worker for seconds. The stack trace names the function. For a low-traffic site on a small VPS, pm = ondemand starts workers only when requests arrive and frees memory when idle.

Cause 3: your own rate limit returns 503

nginx’s limit_req and limit_conn reject excess requests with 503 unless you say otherwise, so a rate limit that is too tight looks exactly like an outage. The error log gives it away:

/var/log/nginx/error.log
[error] 2263#2263: *7 limiting requests, excess: 2.990 by zone "perip", client: 203.0.113.50, ...

Behind Cloudflare or another proxy this goes wrong in a specific way. Without real-IP handling, $binary_remote_addr is the proxy’s address, so thousands of visitors share a handful of keys and hit the limit together. Restore the visitor IP first with nginx’s realip module (the error 522 guide has the snippet for Cloudflare), then loosen the limit and change its status to 429 Too Many Requests, which names the real reason. Google’s crawlers slow down on a 429 just as they do on a 5xx, but your logs and API clients can now tell a limit from an outage:

nginx
# http {} block
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_status 429;
limit_conn_status 429;

# server {} or location {} block
limit_req zone=perip burst=20 nodelay;

Rate limits at your hosting provider or in a CDN rule can produce the same symptom. If neither your logs nor your config show a 503, compare the response with and without the CDN using the --resolve test above.

Cause 4: maintenance mode was never switched off

  • WordPress writes a .maintenance file to the site root during core, plugin and theme updates and answers 503 with Retry-After: 600 while it exists. It ignores the file 10 minutes after the update started, but a stale one should go: sudo rm /var/www/site/.maintenance. If the 503 persists, a maintenance plugin or a cached page is serving it.
  • Laravel: php artisan up undoes php artisan down. With the file-based driver, run it on every server that ran down.
  • Your own nginx switch: if you use a flag file like the one below, check that it is gone.

Planned maintenance: send 503 with Retry-After

For planned downtime, 503 is the right status: it tells browsers, API clients and search engines that the outage is temporary. The Retry-After header says when to come back, either as seconds (Retry-After: 3600) or as an HTTP date. This nginx setup switches maintenance on when a file exists and lets your own IP through:

nginx · server {} block
set $maintenance 0;
if (-f /var/www/maintenance.on) { set $maintenance 1; }
if ($remote_addr = 198.51.100.7) { set $maintenance 0; }   # your IP
if ($maintenance = 1) { return 503; }

error_page 503 @maintenance;
location @maintenance {
    root /var/www/maintenance;              # contains index.html
    rewrite ^ /index.html break;
    add_header Retry-After 3600 always;
}
Debian · Ubuntu
sudo nginx -t && sudo systemctl reload nginx
sudo touch /var/www/maintenance.on     # maintenance on
sudo rm /var/www/maintenance.on        # maintenance off

Keep it short. Google treats a 503 as temporary and slows crawling, but its own guidance is to use it for a day or two at most; pages that keep returning errors for longer start dropping out of the index.

Turn restart gaps into a clean 503

When nginx proxies to an app that you restart on every deploy, the few seconds without a backend show up as 502s. Answering them as a 503 with Retry-After is more honest to clients and crawlers. The catch: a backend that stays dead now also shows up as a 503, so make sure your monitoring alerts on it:

nginx
location / {
    proxy_pass http://127.0.0.1:3000;
    error_page 502 504 =503 @restarting;
}
location @restarting {
    add_header Retry-After 30 always;
    return 503;
}

How to prevent 503 errors

  • Restart crashed services automatically: Restart=on-failure in the app’s systemd unit. Reload PHP-FPM with systemctl reload instead of restart, and set process_control_timeout = 10s in /etc/php/8.3/fpm/php-fpm.conf: at the default of 0, even a reload ends requests that are still running.
  • Size PHP-FPM from measured memory and keep the slow log on, so you see hanging requests before they starve the pool.
  • Return 429 from rate limits, and only after real-IP handling is in place.
  • Health checks that match reality: in HAProxy, Caddy or Traefik, check an endpoint that touches the database, not a static file, so a broken backend is taken out before visitors hit it.
  • Give the stack room: pool exhaustion on a box that is already at its memory limit cannot be configured away. A Standard KVM plan with more RAM fits more workers, and Hi-CPU KVM on AMD EPYC with ECC memory is built for workloads that lean on one or two cores rather than many, which is how PHP runs: each request on a single core. Shorter requests free their worker sooner, which is the cheapest way to raise capacity.

If the 503 turns out to be a 500 in disguise, for example an app that crashes on every request, the 500 Internal Server Error guide covers reading those logs.

Frequently asked

What is the difference between a 503 and a 502 error?

A 502 Bad Gateway means a proxy got no valid answer from the backend behind it. A 503 Service Unavailable means the server is deliberately not handling the request right now, because of overload, rate limiting or maintenance. nginx reports a dead backend as 502, while Apache, HAProxy and Varnish report it as 503.

Why does nginx return 503 when my backend is running?

Almost always because of rate limiting: limit_req and limit_conn reject excess requests with status 503 by default, and the error log shows 'limiting requests' or 'limiting connections'. Behind Cloudflare, check that real-IP handling is set up, otherwise all visitors share the proxy's IP and hit the limit together.

Should I return 503 during planned maintenance?

Yes. A 503 with a Retry-After header tells browsers, API clients and search engines that the outage is temporary and when to come back. Keep it to a day or two at most, since Google may start dropping pages that keep returning errors for longer.

Related articles

All troubleshooting