Fixing Nginx 'connect() failed (113: No route to host)' for IPv6-Only Upstream Backends
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–45 mins
TL;DR
- What broke: Nginx cannot open a TCP socket to an IPv6-only upstream — the kernel returns
EHOSTUNREACH (113), meaning no route exists at the socket layer, not just DNS. - How to fix it: Confirm IPv6 is enabled on the host, wrap the upstream address in brackets (
[::1]:8080), ensure the resolver returns AAAA records, and verify the upstream server is actually bound to its IPv6 address. - Fast path: Use our Client-Side Sandbox below to auto-refactor your failing Nginx upstream block — paste the config, get corrected diff output without sending your IPs or hostnames to any third-party server.
The Incident (What Does the Error Mean?)
Raw error from /var/log/nginx/error.log:
2024/07/15 03:42:17 [error] 1187#1187: *9831 connect() failed (113: No route to host)
while connecting to upstream,
client: 203.0.113.44, server: api.internal,
request: "POST /v1/ingest HTTP/1.1",
upstream: "http://[2001:db8::cafe:1]:9000/v1/ingest",
host: "api.internal"
Errno 113 (EHOSTUNREACH) is not a DNS failure and not a firewall REJECT. It means the kernel's routing table has no path to the destination network at the time connect() is called. The TCP SYN never leaves the NIC. This is distinct from:
| Errno | Meaning | Nginx log text |
|---|---|---|
| 111 | Connection refused (port closed) | connect() failed (111: Connection refused) |
| 110 | Timeout (firewall DROP) | upstream timed out (110: Connection timed out) |
| 113 | No route (kernel routing gap) | connect() failed (113: No route to host) |
Immediate consequence: Every proxied request to this upstream returns 502 Bad Gateway instantly. There is no retry delay — the failure is synchronous and hard. If this upstream is in an upstream {} block with other peers, Nginx marks it down after max_fails and stops sending traffic to it entirely until the fail_timeout window expires.
The Attack Vector / Blast Radius
This is a silent availability failure, not a security exploit — but the blast radius is severe in production:
1. Full upstream eviction from the load-balancing pool. Nginx's passive health check marks the peer unavailable. If this is your only upstream or all upstreams are IPv6-only, 100% of traffic 502s. There is no circuit-breaker grace period.
2. Cascading retry storms. Clients retrying on 502 multiply request rate. If a CDN or API gateway sits in front, it may retry 3× per origin request, tripling the error log volume and masking the root cause in noise.
3. Split-brain dual-stack environments.
In Kubernetes or Docker with --ipv6 disabled at the daemon level, the container's network namespace has no default IPv6 route (ip -6 route returns empty). Nginx running inside that container will always get errno 113 for any IPv6 upstream, even ::1. This is the most common cause and the most commonly missed.
4. Resolver returning AAAA but kernel can't route it.
If resolver 8.8.8.8 resolves a hostname to an IPv6 address but the host has no IPv6 default gateway (common in legacy bare-metal or certain AWS EC2 instance types with IPv6 not explicitly enabled on the ENI), every dynamic upstream resolution produces an unroutable address and errno 113 follows.
How to Fix It (The Solution)
Root Cause Checklist — Run These First
# 1. Is IPv6 enabled in the kernel at all?
cat /proc/sys/net/ipv6/conf/all/disable_ipv6
# 0 = enabled, 1 = DISABLED (this is your culprit if 1)
# 2. Does a default IPv6 route exist?
ip -6 route show default
# Empty output = no gateway = errno 113 guaranteed
# 3. Can the Nginx worker process reach the upstream directly?
ip netns exec <nginx-netns-if-containerized> ping6 -c3 2001:db8::cafe:1
# 4. Is the upstream actually listening on its IPv6 address?
ss -tlnp | grep 9000
# Must show [::]:9000 or 2001:db8::cafe:1:9000, NOT 0.0.0.0:9000
Fix 1 — Basic: Nginx Config Syntax (Missing Brackets)
The most trivial cause: IPv6 addresses in Nginx upstream blocks must be wrapped in square brackets. Without them, Nginx misparses the address.
upstream backend_ipv6 {
- server 2001:db8::cafe:1:9000; # WRONG: Nginx parses ':9000' as part of address
+ server [2001:db8::cafe:1]:9000; # CORRECT: brackets isolate the address
}
server {
listen 80;
- listen 443 ssl;
+ listen [::]:80;
+ listen [::]:443 ssl;
location /api/ {
proxy_pass http://backend_ipv6;
}
}
Fix 2 — Kernel IPv6 Disabled at Runtime
# /etc/sysctl.d/99-ipv6.conf
-net.ipv6.conf.all.disable_ipv6 = 1
-net.ipv6.conf.default.disable_ipv6 = 1
+net.ipv6.conf.all.disable_ipv6 = 0
+net.ipv6.conf.default.disable_ipv6 = 0
+net.ipv6.conf.lo.disable_ipv6 = 0
Apply without reboot: sysctl --system
Fix 3 — Enterprise Best Practice: Resolver + Upstream with Keepalive and Health Checks
In dynamic environments (service mesh, Consul, k8s headless services), hardcoded IPv6 addresses are fragile. Use Nginx Plus active health checks or the OSS ngx_http_upstream_hc_module workaround with a proper resolver.
http {
+ # Force resolver to return AAAA records; set valid TTL to prevent stale routes
+ resolver [2001:4860:4860::8888] [2001:4860:4860::8844] valid=10s ipv6=on;
+ resolver_timeout 3s;
upstream backend_ipv6 {
- server backend.internal:9000; # Resolves to IPv6 but no ipv6=on resolver set
+ server backend.internal:9000 resolve; # Requires resolver directive above
+ keepalive 32;
+ keepalive_requests 1000;
+ keepalive_timeout 75s;
}
server {
- listen 80;
+ listen 80;
+ listen [::]:80 ipv6only=off; # Single socket handles both IPv4 and IPv6
location /api/ {
proxy_pass http://backend_ipv6;
proxy_http_version 1.1;
- proxy_set_header Connection "";
+ proxy_set_header Connection ""; # Required for keepalive upstream
+ proxy_connect_timeout 3s; # Fail fast; don't wait 60s on bad route
+ proxy_next_upstream error timeout http_502 http_503;
+ proxy_next_upstream_tries 2;
}
}
}
Fix 4 — Docker / Kubernetes: Enable IPv6 in the Container Runtime
# /etc/docker/daemon.json
{
+ "ipv6": true,
+ "fixed-cidr-v6": "fd00::/80",
"log-driver": "json-file"
}
For Kubernetes, patch the kubeadm config or CNI plugin (Calico, Cilium) to enable dual-stack. Cilium example:
# cilium-config ConfigMap
- enable-ipv6: "false"
+ enable-ipv6: "true"
+ ipv6-range: "fd00::/104"
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Nginx Config Linting in Pre-Commit / PR Pipeline
# .github/workflows/nginx-lint.yml
- name: Validate Nginx config
run: |
docker run --rm -v $(pwd)/nginx:/etc/nginx:ro nginx:alpine \
nginx -t -c /etc/nginx/nginx.conf
# Fails the pipeline if brackets are missing or directives are invalid
2. Checkov Policy — Detect Missing IPv6 Listen Directives
Checkov doesn't ship an Nginx IPv6 check out of the box. Write a custom policy:
# checkov/custom_checks/nginx_ipv6_upstream.py
from checkov.common.models.enums import CheckResult
from checkov.nginx.checks.base_nginx_check import BaseNginxCheck
import re
class NginxIPv6UpstreamBrackets(BaseNginxCheck):
def __init__(self):
super().__init__(
name="Ensure IPv6 upstream addresses use bracket notation",
check_id="CKV_NGINX_IPV6_UPSTREAM"
)
def check_resource_conf(self, conf):
# Regex: bare IPv6 address (colon-separated hex) NOT wrapped in brackets before :port
bare_ipv6 = re.compile(r'server\s+[0-9a-fA-F:]{3,}:[0-9]+')
for line in conf.get('raw_lines', []):
if bare_ipv6.search(line):
return CheckResult.FAILED
return CheckResult.PASSED
3. OPA / Conftest Policy for Kubernetes Nginx Ingress
# policy/nginx_ipv6.rego
package nginx.ipv6
deny[msg] {
input.kind == "ConfigMap"
input.metadata.name == "nginx-configuration"
val := input.data["use-ipv6"]
val == "false"
msg := "Nginx Ingress must have use-ipv6 enabled when IPv6-only backends are in use"
}
4. Smoke Test in Staging — Validate Route Before Deploy
#!/usr/bin/env bash
# ci/check_ipv6_route.sh — run this as a pre-deploy gate
UPSTREAM_IPV6="2001:db8::cafe:1"
if ! ip -6 route get "${UPSTREAM_IPV6}" &>/dev/null; then
echo "FATAL: No IPv6 route to ${UPSTREAM_IPV6}. Aborting deploy."
exit 1
fi
echo "IPv6 route confirmed. Proceeding."
Add this as a required status check in your GitHub branch protection rules. A 2-second script that has saved hours of production debugging.