Back to blog
Guide September 15, 2026

Troubleshooting NGINX emerg bind failed 98 Address Already in Use: Port Conflicts, Zombie Workers, and SO_REUSEPORT

Automate WhatsApp Alerts
Start Free ➔

During automated deployments, server reboots, or rolling container updates, few NGINX errors bring down production ingress as abruptly as emerg bind failed (98: Address already in use):

2026/09/15 03:41:10 [emerg] 14820#14820: bind() to 0.0.0.0:80 failed (98: Address already in use)
2026/09/15 03:41:10 [emerg] 14820#14820: bind() to 0.0.0.0:443 failed (98: Address already in use)
2026/09/15 03:41:10 [emerg] 14820#14820: bind() to [::]:80 failed (98: Address already in use)
2026/09/15 03:41:10 [emerg] 14820#14820: still could not bind()
nginx: configuration file /etc/nginx/nginx.conf test failed

When this emergency occurs, the NGINX master process exits immediately with a non-zero exit code (status=1/FAILURE). The reverse proxy stops accepting incoming traffic, and downstream health checks fail across cloud load balancers.

Crucially, running nginx -t often reports syntax validity even while systemctl restart nginx fails. Syntax checks validate directive grammar; they cannot guarantee that the requested network interfaces and ports are available in the Linux kernel socket table at runtime.

In this deep-dive guide, we dissect Linux socket binding mechanics, decode the EADDRINUSE (errno 98) system call, diagnose competing daemons, fix IPv4/IPv6 dual-stack collisions, resolve systemd race conditions, and provide a production SRE runbook.


1. Understanding bind failed 98: What Happens at the Kernel Layer

In POSIX operating systems, network communication relies on Berkeley sockets. When NGINX starts, its master process executes a standard sequence of kernel system calls:

socket()   --> Creates an unbound socket file descriptor (AF_INET / AF_INET6)
   |
bind()     --> Binds the socket to a local IP address and Port (e.g., 0.0.0.0:80)
   |           [FAILS HERE WITH ERRNO 98 / EADDRINUSE]
listen()   --> Marks the socket as passive, defining the listen backlog
   |
accept()   --> Worker processes accept incoming client TCP 3-way handshakes

When the Linux kernel receives the bind(sockfd, addr, addrlen) syscall, it inspects its internal TCP/UDP port hash tables. If another active socket already claims that exact IP/Port combination without compatible socket sharing flags (SO_REUSEPORT), the kernel returns -1 and sets errno = 98 (EADDRINUSE).

NGINX Master Process
  |
  +--- syscall: bind(fd, { family: AF_INET, addr: 0.0.0.0, port: 80 })
  |
  v
Linux Kernel TCP Port Hash Table
  |
  +---> Found matching socket already in LISTEN state!
  |     (Owner: Apache, rogue NGINX master, or Docker proxy)
  |
  v
Kernel returns: -1 EADDRINUSE (Errno 98: Address already in use)
  |
  v
NGINX logs: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)

2. Linux Socket Binding Mechanics & Wildcard Binds

A fully established TCP connection is identified by a 4-tuple: $$ (\text{Source IP}, \text{Source Port}, \text{Destination IP}, \text{Destination Port}) $$

However, a listening socket only binds to a local 2-tuple: $$ (\text{Local IP}, \text{Local Port}) $$

The Wildcard Overlap Conflict

When a process binds to the IPv4 wildcard address 0.0.0.0:80, it claims port 80 across every IPv4 network interface on the host.

If NGINX tries to bind specifically to 192.168.1.50:80 while another process (or container) is bound to 0.0.0.0:80, the kernel rejects NGINX's request with EADDRINUSE because the wildcard listener already subsumes that interface.

+-----------------------------------------------------------------------------+
|                     LINUX SOCKET BIND COLLISION MATRIX                      |
+----------------------+----------------------+-------------------------------+
| Existing Socket      | Attempted New Bind   | Kernel Result                 |
+----------------------+----------------------+-------------------------------+
| 0.0.0.0:80           | 0.0.0.0:80           | CONFLICT (EADDRINUSE)         |
| 0.0.0.0:80           | 192.168.1.10:80      | CONFLICT (EADDRINUSE)         |
| 192.168.1.10:80      | 0.0.0.0:80           | CONFLICT (EADDRINUSE)         |
| 127.0.0.1:80         | 192.168.1.10:80      | ALLOWED (Distinct Interfaces) |
| [::]:80 (ipv6only=0) | 0.0.0.0:80           | CONFLICT (Dual-stack overlap) |
| [::]:80 (ipv6only=1) | 0.0.0.0:80           | ALLOWED (Isolated Stacks)     |
+----------------------+----------------------+-------------------------------+

3. The 6 Root Causes of NGINX Error 98

In production environments, error 98 is triggered by one of six distinct architectural root causes:

                               +-----------------------------+
                               |     ROOT CAUSES OF 98       |
                               +--------------+--------------+
                                              |
      +-------------------+-------------------+-------------------+-------------------+
      |                   |                   |                   |                   |
      v                   v                   v                   v                   v
+-------------+     +-------------+     +-------------+     +-------------+     +-------------+
| 1. Competing|     | 2. Rogue    |     | 3. Duplicate|     | 4. IPv6     |     | 5. Systemd  |
| Daemon      |     | Master      |     | Listeners   |     | Dual-Stack  |     | Startup Race|
+-------------+     +-------------+     +-------------+     +-------------+     +-------------+
| Apache/httpd|     | Manually    |     | Multiple    |     | [::]:80     |     | Old worker  |
| Caddy/Envoy |     | spawned     |     | default     |     | capturing   |     | draining    |
| Docker host |     | binary      |     | server      |     | IPv4        |     | slow        |
+-------------+     +-------------+     +-------------+     +-------------+     +-------------+

4. First-Line Triage: Identifying the Conflicting Process

Do not blindly guess or reboot the server. Use Linux socket diagnostics to identify the exact PID and binary claiming the port.

1. The `ss` Command (Modern Linux Standard)

```bash

Inspect all TCP listening sockets on port 80 and 443 with PID/Process mapping

sudo ss -ltnp 'sport = :80 or sport = :443' ```

Representative Output: ```text State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("apache2",pid=38421,fd=4),("apache2",pid=38422,fd=4)) LISTEN 0 128 0.0.0.0:443 0.0.0.0:* users:(("docker-proxy",pid=9124,fd=4)) ```

2. The `lsof` and `fuser` Commands

```bash

Inspect active listeners via file descriptors

sudo lsof -nP -iTCP:80 -sTCP:LISTEN sudo lsof -nP -iTCP:443 -sTCP:LISTEN

Identify the process tree and user owning port 80

sudo fuser -v 80/tcp ```


5. Diagnosing & Resolving Root Cause Scenarios

Root Cause 1: Duplicate NGINX Master Processes (Orphaned Daemons)

When administrators launch NGINX manually via /usr/sbin/nginx and subsequently execute systemctl restart nginx, two independent master processes compete for the same PID file and port.

# Check all running NGINX processes
ps -ef | grep '[n]ginx'
pgrep -a nginx

# Check process parentage
pstree -ap "$(pgrep -o nginx)"

Resolution:

# Gracefully stop the systemd service
sudo systemctl stop nginx

# Terminate any remaining orphaned NGINX worker/master processes
sudo pkill -f nginx

# Verify ports are clear
sudo ss -ltnp 'sport = :80 or sport = :443'

# Start NGINX cleanly under systemd supervision
sudo systemctl start nginx

Root Cause 2: Competing Web Server (Apache/Caddy/Traefik)

On Ubuntu/Debian systems, installing packages like php-fpm or certbot can automatically pull in and start apache2.

# Check if apache2 or caddy is running
sudo systemctl status apache2 httpd caddy 2>/dev/null

Resolution:

# Stop and permanently disable the competing daemon
sudo systemctl stop apache2
sudo systemctl disable apache2

# Start NGINX
sudo systemctl restart nginx

Root Cause 3: Duplicate NGINX listen Configurations

Multiple server { ... } blocks can share port 80 as long as they route via different server_name virtual hosts. However, you cannot declare conflicting port-level parameters (e.g. default_server, ssl, or reuseport) more than once per IP/Port.

# CONFLICT: Duplicate default_server declaration
server {
    listen 80 default_server;
    server_name example.com;
}

server {
    listen 80 default_server; # ERROR: Cannot have two default servers on 0.0.0.0:80
    server_name api.example.com;
}

Diagnostic Command:

# Dump the ENTIRE compiled configuration (including all conf.d and sites-enabled includes)
sudo nginx -T | grep -nE '^[[:space:]]*listen[[:space:]]'

Root Cause 4: IPv4 vs IPv6 Dual-Stack Collisions

On Linux kernels where net.ipv6.bindv6only = 0, an IPv6 wildcard socket [::]:80 binds to both IPv6 and IPv4.

# CONFLICT: Dual-stack collision
server {
    listen 80;        # Binds 0.0.0.0:80 (IPv4)
    listen [::]:80;   # Attempts to bind [::]:80 AND 0.0.0.0:80 (FAILS!)
    server_name api.pingzoapp.com;
}

The Hardened Fix: Explicit ipv6only=on

server {
    listen 80;
    listen [::]:80 ipv6only=on;
    server_name api.pingzoapp.com;
}

Root Cause 5: Docker & Container Port Publishing Collisions

If a Docker container is started with -p 80:80 or network_mode: host, docker-proxy binds to 0.0.0.0:80 on the host, preventing host-level NGINX from starting.

# Inspect all Docker published ports
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'

# Find which container published port 80/443
docker ps --format '{{.ID}} {{.Names}} {{.Ports}}' | grep -E '(:80->|:443->)'

Resolution: Update your docker-compose.yml to bind the container to an internal loopback port (e.g. 127.0.0.1:8080:80) and let NGINX reverse-proxy traffic to it.


Root Cause 6: Systemd Restart Races & Worker Draining Delays

During systemctl restart nginx, systemd sends SIGTERM to the old NGINX master. If worker processes are draining large file uploads or slow connections, they remain alive briefly. If the new master executes bind() before the old master releases the socket, restart fails.

Timeline of a Systemd Restart Race:
t=0.0s : systemd stops NGINX (SIGTERM sent)
t=0.1s : Old master begins draining workers (Socket still bound)
t=0.2s : systemd executes ExecStart (New NGINX master launches)
t=0.3s : New NGINX calls bind(0.0.0.0:80) -> FAILS (EADDRINUSE)
t=0.5s : Old master finishes draining and exits (Too late!)

The SRE Fix: Graceful Reloads instead of Restarts Never use systemctl restart nginx for configuration updates. Always use graceful reloads:

# 1. Validate syntax
sudo nginx -t

# 2. Issue SIGHUP (Zero Downtime, zero socket re-binding)
sudo systemctl reload nginx

6. Debunking the TIME_WAIT Misdiagnosis

A widespread myth among developers is that connections in TIME_WAIT prevent NGINX from binding port 80.

ss -tan state time-wait

Why this is false:

  1. TIME_WAIT applies to connected client 4-tuples, not listening sockets.
  2. NGINX opens all listening sockets with the SO_REUSEADDR kernel socket option.
  3. SO_REUSEADDR allows the kernel to immediately bind a listening socket to a port even if previous client connections on that port are lingering in TIME_WAIT.

If NGINX fails with EADDRINUSE, the port is owned by an active LISTEN socket, not a TIME_WAIT socket.


7. Production-Ready Automated Diagnostic Script

Save this script as diagnose-nginx-bind.sh on your servers for instant automated triage during incidents:

#!/usr/bin/env bash
# Pingzo SRE Diagnostic Collector for NGINX Port Collisions
set -euo pipefail

PORT="${1:-80}"
echo "========================================================"
echo " PINGZO SRE DIAGNOSTIC: INSPECTING TCP/UDP PORT ${PORT}"
echo "========================================================"

echo -e "\n[1] Checking Active Sockets (ss):"
sudo ss -lntup "sport = :${PORT}" || true

echo -e "\n[2] Checking File Descriptor Ownership (lsof):"
sudo lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN || true

echo -e "\n[3] Checking NGINX Master & Worker Processes:"
pgrep -a nginx || echo "No NGINX processes detected."

echo -e "\n[4] Validating NGINX Configuration (nginx -t):"
sudo nginx -t 2>&1 || true

echo -e "\n[5] Checking Systemd Unit Status:"
sudo systemctl status nginx --no-pager -n 5 || true

echo -e "\n[6] Recent Systemd Journal Logs:"
sudo journalctl -u nginx --since "15 minutes ago" --no-pager | tail -n 15 || true

echo -e "\n========================================================"
echo " DIAGNOSTIC COMPLETE"
echo "========================================================"

Make it executable:

chmod +x diagnose-nginx-bind.sh
./diagnose-nginx-bind.sh 80
./diagnose-nginx-bind.sh 443

8. Verifying Recovery at the Protocol Layer

Once the listener is restored, verify that Layer 4, TLS, and HTTP are functioning correctly:

# 1. Verify socket is in LISTEN state on both IPv4 and IPv6
sudo ss -lntp | grep -E '(:80|:443)'

# 2. Test local HTTP loopback
curl -I http://127.0.0.1/

# 3. Test local HTTPS / TLS handshake with ALPN negotiation
openssl s_client -connect 127.0.0.1:443 -servername api.pingzoapp.com -alpn h2,http/1.1 < /dev/null

[!TIP] After restoring local listeners, use Pingzo's interactive SSL Inspector and HTTP Header Checker to verify external reachability, SSL certificate validity, and edge CDN routing from 12+ global vantage points.


9. SRE Incident Decision Matrix & Runbook

Follow this operational matrix when responding to error 98 alerts:

Observed DiagnosticLikely Root CauseImmediate RemediationSRE Hardening Action
Port 80 owned by `apache2`Apache service started after package install`sudo systemctl stop apache2 && sudo systemctl disable apache2`Remove unused Apache packages
Port 443 owned by old NGINX PIDOrphaned master outside systemd`sudo pkill -9 -f nginx && sudo systemctl start nginx`Enforce CI/CD deployment via systemd
Conflict on `[::]:80`IPv6 dual-stack collisionAdd `ipv6only=on` to `listen [::]:80` directiveStandardize dual-stack NGINX templates
Port owned by `docker-proxy`Container published host portChange container port mapping to internal IPRestrict container ports in Docker Compose
`nginx -t` passes but restart failsSystemd restart raceUse `sudo systemctl reload nginx`Update CI scripts to use `reload`

10. Production Checklist Before Closing the Incident:

  • Active Listeners Verified: Confirm 0.0.0.0:80, 0.0.0.0:443, and [::]:443 are in LISTEN state.
  • Single Master Process: Confirm only one master process exists (pgrep -a nginx | grep master).
  • Systemd Unit Clean: Confirm systemctl is-active nginx returns active.
  • Zero Downtime Deployments Configured: Replace all systemctl restart nginx deployment hooks with nginx -t && systemctl reload nginx.
  • Continuous Edge Monitoring: Ensure Pingzo monitors port 80 and 443 availability with instant WhatsApp escalations.
Zero-Code Uptime Alerts

Stop Finding Out About Outages from Angry Users

Get instant WhatsApp & Discord alerts the second your API, website, or server goes down. Setup in 30 seconds with 60-second checks.

WhatsApp & Discord 60-Second Checks Free Forever Plan
Try Pingzo Free

Know before your users do

Connect official WhatsApp notification channels, Discord webhooks, Telegram bots, and public status pages. Start in 30 seconds.

Create Free Monitor