Nginx Upstream Service Discovery in Docker Compose#
The Core Incompatibility#
Docker Compose (and Swarm) uses DNS round-robin to load-balance scaled replicas: when app-server is scaled to 3 replicas, the service name app-server resolves to all three containers in round-robin. There are no separate DNS entries like app-server-1, app-server-2, app-server-3.
Nginx, however, behaves differently from most tools: it resolves hard-coded names (in upstream blocks or proxy_pass directives) only once when the configuration loads, not per-request . This causes two classes of failure when using Nginx as a load balancer in front of Docker-managed replicas.
Failure Mode 1 — Startup crash with hard-coded upstream names#
A static upstream block with hard-coded Docker service names fails immediately if Nginx starts before the backend:
# ❌ Fails at startup: hard-coded name is resolved once at configuration load
upstream app_servers {
server app-server:8080;
}
Error: nginx: [emerg] host not found in upstream "app-server:8080" — the container enters a Created / crash-loop state and never becomes healthy .
Adding resolver alone does NOT fix this: tested with nginx 1.31.6 and Docker 29.8.0, adding resolver 127.0.0.11; produces the same error and the same startup failure. Hard-coded names in upstream blocks are resolved at configuration load time, and the resolver directive is ignored for them.
Actual fixes: To achieve runtime DNS resolution for Docker container names, use one of these approaches:
- Variable-based
proxy_pass(no upstream block):
http {
resolver 127.0.0.11 valid=30s ipv6=off;
server {
listen 80;
location / {
set $backend_host "app-server:8080";
proxy_pass http://$backend_host; # Resolved per-request
}
}
}
upstreamwithserver ... resolve(NGINX 1.27.3+):
http {
resolver 127.0.0.11 valid=30s ipv6=off;
upstream backend {
zone backend 64k; # Required with resolve
server app-server:8080 resolve;
}
# Then: proxy_pass http://backend;
}
The variable approach responds immediately when the backend becomes available. The resolve approach can have up to 30 seconds of 502 errors (the valid duration) after the service starts.
See the resolver directive section for parameter details.
127.0.0.11is Docker's internal DNS and is only available inside user-defined networks (not the defaultbridge).
Failure Mode 2 — Indexed Names in Upstream Blocks vs. DNS Round-Robin#
The exam project's "High Availability" complement demonstrates a static indexed upstream configuration :
upstream app_servers {
least_conn;
server app-server-1:8080;
server app-server-2:8080;
server app-server-3:8080;
}
And a matching rolling-update script that iterates those names :
for instance in app-server-1 app-server-2 app-server-3; do
docker compose stop $instance
docker compose pull $instance
docker compose up -d $instance
until [ "$(docker inspect --format='{{.State.Health.Status}}' $instance)" == "healthy" ]; do
sleep 5
done
done
Why this silently fails or loops with deploy.replicas:
When you use deploy: replicas: 3 on a service named app-server, Docker Compose creates three containers but registers them all under the single DNS name app-server using round-robin. No DNS entries for app-server-1, app-server-2, or app-server-3 exist .
Consequences:
- The Nginx upstream entries for
app-server-1etc. resolve to nothing →host not found→ Nginx fails to start or marks those backends as down. - The rolling-update script's
docker compose stop app-server-1targets a non-existent service name, sodocker inspectnever returnshealthy→ infinite loop.
The two approaches that actually work:
| Approach | How to scale | Nginx config | Trade-offs |
|---|---|---|---|
| DNS round-robin (recommended for Compose) | deploy: replicas: N on app-server | Variable-based proxy_pass (see below) or upstream with server ... resolve | Variable approach: per-request resolution. Resolve approach: periodic refresh (up to valid delay) |
| Explicit indexed services | Define app-server-1, app-server-2, etc. as separate services in compose.yml | Static upstream entries work | Not idiomatic Compose; manual scaling |
For the variable-based approach that forces per-request DNS resolution :
http {
resolver 127.0.0.11 valid=30s ipv6=off;
server {
listen 80;
location / {
set $backend_host "app-server:8080";
proxy_pass http://$backend_host; # Forces DNS re-resolution per request
}
}
}
Note:
resolveralone has no effect on a staticupstreamblock with hard-coded server names — tested with nginx 1.31.6 and Docker 29.8.0, addingresolverto a static upstream configuration produces the same startup failure. To achieve runtime resolution with an upstream block, useserver ... resolve(NGINX 1.27.3+) with azonedirective.
Quick Reference#
| Situation | Result |
|---|---|
Upstream with hard-coded Docker service name, no resolver | Nginx fails to start: host not found |
Upstream with hard-coded Docker service name + resolver 127.0.0.11 | Still fails to start — resolver has no effect on hard-coded upstream names |
Upstream with server ... resolve + resolver + zone | Starts OK; resolves periodically (at valid interval) |
proxy_pass with $variable + resolver | Dynamic DNS re-resolution per request |
Rolling-update script targeting app-server-N with replicas: N | Silent no-op; health check loops forever |
resolver used on default bridge network | Fails — DNS server only exists in user-defined networks |
Key Sources#
- COURSE_IMPROVEMENTS_DOCKER_NETWORKING.adoc — root-cause analysis from real student exam failures, with before/after grade impact
- content/chapitres/reseaux.adoc §"Piège NGINX" — course slide content on the resolver trap and the resolver + variable pattern
- content/examen-final-detaille.adoc §"Complément 5 : Haute disponibilité" — the HA complement with the indexed upstream config and rolling-update script