Nginx port conflict with Node.js in air-gapped environment

Problem: Nginx Fails to Bind Because a Node.js Process Holds the Desired Port

In an air‑gapped deployment the nginx service is started after a Node.js micro‑service that is configured to listen on the same TCP port (commonly 80 or 443). Because the environment has no internet connectivity, developers cannot pull remote debugging tools or replace binaries on‑the‑fly. The result is a startup failure such as:

nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
2026/07/05 12:03:41 [error] 1024#1024: *1 bind() to 0.0.0.0:80 failed (98: Address already in use), while listening on 0.0.0.0:80, client: 127.0.0.1, server: _, request: "GET / HTTP/1.1"
systemd: nginx.service: Main process exited, code=exited, status=1/FAILURE

Similar messages appear for HTTPS (port 443) or any custom port that Nginx is expected to expose.

Root Cause Analysis

The NGINX listen directive tells each worker to call bind() on the configured address/port. If another process already has an open listening socket on that address, the kernel returns EADDRINUSE (error 98). Nginx treats this as an emerg condition and aborts startup (error_log documentation).

In the described air‑gapped scenario the conflict originates from one of the following patterns:

  • A systemd unit that starts a Node.js health‑check daemon on port 443 before nginx.service is enabled.
  • A Docker‑Compose file that brings up a Node.js container with ports: "80:80" before the Nginx container, causing the host port to be occupied (GitHub Issue).
  • A Helm chart that deploys a Node.js sidecar pod with hostPort: 80 ahead of the Nginx Ingress controller (GitHub Issue).

Because the environment is offline, typical remediation steps that rely on pulling new images or installing lsof are unavailable, so the investigation must rely on built‑in tools (ss, netstat, systemctl, and Nginx’s own logs).

Investigation and Debugging

Follow these steps on the target host. All commands are available in a minimal Linux base image.

1. Identify the conflicting socket

# Show listening sockets with the owning process
ss -tlnp | grep ':80' || true
# Example output
LISTEN 0      128          0.0.0.0:80          0.0.0.0:*    users:(("node",pid=2456,fd=23))

2. Verify Nginx configuration

# Test the config without starting the daemon
nginx -t -c /etc/nginx/nginx.conf
# Expected output when the port is free
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If the test passes but the service still fails, the conflict is runtime‑only (i.e., the Node.js process starts after the config test).

3. Correlate systemd units

# List units that depend on the same port
systemctl list-units --type=service | grep -i nginx
systemctl list-units --type=service | grep -i node

4. Capture a short packet trace (optional, no external tools needed)

# Capture the SYN packets that trigger the bind failure
tcpdump -i any -n port 80 -c 5

In an air‑gapped environment the trace will show no inbound traffic; the failure occurs before any packet is accepted.

5. Check Nginx error log for the exact error

cat /var/log/nginx/error.log | grep 'bind()'
# Sample line
2026/07/05 12:03:41 [emerg] 1024#1024: *1 bind() to 0.0.0.0:80 failed (98: Address already in use)

Resolution

The fix consists of ensuring that Nginx is the sole listener on the intended port. Choose one of the following approaches based on operational constraints.

Option A – Reassign the Node.js port

Modify the Node.js service to listen on an unused high‑range port (e.g., 8081) and keep Nginx on the public port.

// Before (node-app.js)
const http = require('http');
http.createServer(app).listen(80);

// After
const http = require('http');
http.createServer(app).listen(8081);

Update any internal references (e.g., upstream block) accordingly:

# nginx.conf – before
upstream node_app {
    server 127.0.0.1:80;
}

# after
upstream node_app {
    server 127.0.0.1:8081;
}

Option B – Let Nginx proxy to the Node.js port

If the Node.js process must stay on the same port for legacy reasons, configure Nginx to listen on a different external port and use a proxy_pass to the Node.js socket.

# nginx.conf – before
listen 80;

# after
listen 8080;
location / {
    proxy_pass http://127.0.0.1:80;
}

Option C – Change service start order

When using systemd, add a dependency so that Nginx starts before the Node.js unit, and configure the Node.js unit with ExecStartPre that checks port availability.

# /etc/systemd/system/node.service
[Unit]
Description=Node.js application
After=network.target nginx.service
Wants=nginx.service

[Service]
ExecStartPre=/usr/bin/bash -c 'while ss -tlnp | grep -q ":80"; do sleep 1; done'
ExecStart=/usr/bin/node /opt/app/node-app.js
Restart=on-failure

[Install]
WantedBy=multi-user.target

This ensures Nginx binds first; the Node.js process will wait until the port is free (or you change its listen port as in Option A).

Option D – Use a Unix domain socket for Nginx ↔ Node.js communication

Replace TCP ports with a file‑system socket, eliminating port collisions entirely.

# Node.js (after)
const http = require('http');
http.createServer(app).listen('/var/run/node.sock');

// nginx.conf
upstream node_app {
    server unix:/var/run/node.sock;
}
server {
    listen 80;
    location / { proxy_pass http://node_app; }
}

Make sure the socket file has appropriate permissions for the Nginx worker process.

Verification

After applying the chosen fix, perform the following checks.

1. Confirm Nginx is listening

# Expected output – port 80 now bound by nginx
ss -tlnp | grep ':80'
LISTEN 0      512          0.0.0.0:80          0.0.0.0:*    users:(("nginx",pid=1024,fd=6))

2. Verify Node.js is on the new port or socket

# For TCP
ss -tlnp | grep ':8081'
# For Unix socket
ls -l /var/run/node.sock

3. Test end‑to‑end request flow

curl -I http://localhost/
# Expected HTTP/1.1 200 OK (or your app’s response)

4. Inspect Nginx error log for absence of bind errors

grep 'bind()' /var/log/nginx/error.log || echo "No bind errors"
# Output
No bind errors

Prevention and Best Practices

  • Port inventory as part of CI/CD validation. In air‑gapped pipelines, maintain a static JSON/YAML file that enumerates reserved ports for each service.
  • Prefer Unix domain sockets for intra‑host communication. This removes the need for TCP port coordination and works without network stack changes.
  • Explicit systemd ordering. Use After= and Wants= directives to guarantee that Nginx starts before any process that might need the same port.
  • Health‑check scripts that fail fast. A pre‑start script that runs ss -tlnp | grep ':PORT' and aborts if the port is occupied prevents silent overwrites.
  • Document port assignments in a version‑controlled file. Include the file in the same repository as the Nginx and Node.js configurations so that reviewers can catch conflicts early.

Related Topic Hub: Distributed Systems Troubleshooting Hub

FAQ

  1. Why does Nginx report “bind() to 0.0.0.0:443 failed (98: Address already in use)” even though no other service is listed on port 443?

    The Node.js daemon may be bound to the same port on the IPv6 address :::443. ss -tlnp without the -4 flag will show both IPv4 and IPv6 listeners. Use ss -tlnp -4 and -6 to see the full picture.

  2. Can I use iptables to block the Node.js process from binding to the port?

    In an air‑gapped system, manipulating firewall rules is possible but does not free the port for Nginx; the kernel still returns EADDRINUSE. The correct approach is to stop the conflicting process or move it to a different socket.

  3. Is it safe to run Nginx with listen 0.0.0.0:80 reuseport; to share the port?

    The reuseport option only works when all processes use the same socket options and are designed to share traffic (e.g., multiple Nginx workers). Mixing Nginx with a generic Node.js server leads to undefined behavior and is not recommended.

  4. How can I automate detection of port conflicts in an offline environment?

    Include a lightweight shell script in the service’s ExecStartPre that runs ss -tlnp | grep ":PORT". If the command finds a match, exit with a non‑zero status to abort the service start, causing systemd to log a clear “port already in use” message.

  5. My Node.js app must listen on 443 for internal health checks. How can I still expose HTTPS via Nginx?

    Run the Node.js health‑check on a Unix domain socket or a high‑range port (e.g., 8443) and configure Nginx to proxy /health to that endpoint. Keep the public 443 listener exclusive to Nginx.