Understanding the 521 Error Code
HTTP status code 521 is specific to Cloudflare’s infrastructure but is widely adopted by other reverse proxies like Nginx to indicate "Web Server Is Down." When Nginx receives this response, it means it successfully contacted the target server (the Express app), but the TCP handshake failed immediately. The most common causes are that the Node.js process has crashed, is not listening on the correct port, or is bound only to localhost while Nginx expects a broader network interface.
Before diving into complex networking diagnostics, you must confirm the health of the application itself. A static React build served by Nginx does not trigger 521 errors; those are strictly associated with proxied API requests or server-side rendering handled by Express. If your React frontend loads but API calls fail, the issue lies entirely within the Express backend connectivity.
Verifying Backend Service Status
The first step is to ensure that your Express application is actually running and responsive. Use system utilities to check the process state and network bindings. Run the following command to list active processes containing node:
ps aux | grep node
If the process is missing, start it using your preferred process manager, such as PM2 or systemd. For example, if using PM2:
pm2 start server.js
Once the process is running, verify which ports it is listening on. Use netstat or ss to inspect the socket status:
sudo ss -tlnp | grep :3000
Look at the local address column. If you see 127.0.0.1:3000, the server is only accepting connections from the local machine. While this works for Nginx running on the same server, it can sometimes cause issues depending on how Nginx resolves the upstream. Ideally, you want to see 0.0.0.0:3000 or *:3000, which binds to all available network interfaces. If the output shows LISTEN next to the port, the service is ready to accept connections.
Testing Connectivity Locally
To isolate whether the issue is with Nginx or the backend, bypass the reverse proxy entirely. Use curl to request data directly from the Express server:
curl http://127.0.0.1:3000/api/health
If this returns a valid JSON response or HTML content, your Express app is functioning correctly. If it hangs or returns a connection refused error, the problem is internal to the Node.js application, not Nginx. In this case, check your Express code to ensure you are calling app.listen(port, '0.0.0.0') rather than just app.listen(port), which defaults to localhost.
If the direct curl test fails, review your Express logs for startup errors, database connection failures, or unhandled exceptions that might cause the process to exit silently. You can also try binding to a different port temporarily to rule out port conflicts with other services on Ubuntu.
Reviewing Nginx Configuration
If the backend is confirmed working via direct access, examine your Nginx site configuration file, typically located in /etc/nginx/sites-available/. Look for the upstream block or the proxy_pass directive within your server block.
server {
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Ensure that the IP address and port in proxy_pass exactly match what ss reported earlier. A common mistake is specifying localhost instead of 127.0.0.1, which can resolve differently depending on IPv6 settings. Also, verify that the Nginx user (www-data) has permission to connect to the port, although this usually results in a 502 error rather than 521.
After making changes, test the configuration syntax before reloading:
sudo nginx -t
If the test passes, reload Nginx:
sudo systemctl reload nginx
For more insights on managing Ubuntu server networks during such deployments, see How do Ubuntu-server network interfaces work?.
Checking Firewall and Security Groups
Although 521 implies the server is reachable but unresponsive, firewall misconfigurations can sometimes interfere with loopback traffic or specific IP bindings. On Ubuntu, check ufw status:
sudo ufw status
Ensure that port 3000 (or your chosen backend port) is allowed if external testing is required. However, for local Nginx-to-Express communication, firewall rules generally do not apply to loopback interfaces. If you are using Cloudflare, ensure that the origin pull certificate and SSL settings are configured correctly, as mismatched TLS versions can also trigger gateway errors.
Additionally, if you recently moved servers or changed network environments, DNS resolution issues might affect how Nginx resolves upstream names. Check Fixing DNS Issues When Moving Headless Ubuntu 22.04.5 LTS Server to a New Network if you suspect hostname resolution problems.
Frequently Asked Questions
Why does my React app load but API calls return 521?
This confirms that Nginx is successfully serving static files but failing when trying to proxy dynamic requests to the Express backend. The issue is isolated to the Node.js process or its network binding, not the Nginx static file serving logic.
Can IPv6 cause a 521 error in Nginx?
Yes, if your Express app binds only to IPv4 (127.0.0.1) but Nginx attempts to connect via IPv6 ([::1]), the connection will be refused. Ensure both services use compatible IP versions or explicitly set the upstream to 127.0.0.1 in Nginx.
How do I check if my Node.js process crashed?
Use pm2 status if you manage processes with PM2, or systemctl status your-service-name for systemd units. Check logs with journalctl -u your-service-name -e to identify fatal errors that caused the crash.
What is the difference between 502 and 521 errors?
A 502 Bad Gateway means Nginx could not get a valid response from the upstream, often due to malformed headers or timeouts. A 521 specifically means the TCP connection was refused immediately upon attempt, indicating the backend service is not listening on that port.
Should I bind Express to 0.0.0.0 or localhost?
Binding to 0.0.0.0 is safer for development and ensures accessibility from any interface. Binding to localhost restricts access to the local machine, which is fine if Nginx runs on the same host but can cause confusion if you switch to Docker or remote debugging.