Fix "No open ports detected" and Port Binding on PaaS Hosts
Fix Django deploys failing on Render, Railway, Fly.io, Cloud Run, and App Runner with no open ports detected, failed to bind to PORT, or application failed to respond: bind Gunicorn to 0.0.0.0 and the platform's PORT variable.
Problem statement
A Django deploy builds successfully and then fails or never becomes healthy:
==> No open ports detected, continuing to scan... (Render)
Application failed to respond (Railway)
The container failed to start and listen on the port defined by PORT (Cloud Run)
instance refused connection. is your app listening on 0.0.0.0:8080? (Fly.io)
Every platform-as-a-service routes traffic to a port inside your container and checks that something listens there. Django apps fail this check for two reasons: Gunicorn listens on 127.0.0.1, which is unreachable from outside the container, or it listens on a different port than the platform expects (often 8000 versus the PORT the platform sets).
- Bind to all interfaces:
--bind 0.0.0.0:$PORT. - Read the port from the environment, with a default:
--bind 0.0.0.0:${PORT:-8000}. - Use a shell form start command so
$PORTexpands (or rely on Gunicorn readingPORTitself). - Make sure the app starts within the platform's boot timeout; move migrations to a pre-deploy step.
- Match any configured internal port (
internal_porton Fly, container port on App Runner) to what Gunicorn uses.
Step-by-step solution
1. Bind to 0.0.0.0, not localhost
gunicorn config.wsgi:application --bind 127.0.0.1:8000 # unreachable from the platform
gunicorn config.wsgi:application --bind 0.0.0.0:8000 # reachable
Inside a container, 127.0.0.1 is the container's own loopback interface. The platform's router and health checker connect over the container's network interface, so they get "connection refused". python manage.py runserver also binds to localhost by default, another reason not to use it in production.
2. Use the platform's PORT
Most platforms set PORT and route traffic to it:
gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000} --workers 2
Gunicorn also reads PORT automatically when no --bind is given (it binds 0.0.0.0:$PORT), so the simplest start command is just gunicorn config.wsgi:application. If you pass --bind, use the variable.
3. Make sure the variable expands
In a Dockerfile, the exec form does not run a shell, so $PORT is passed literally:
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:$PORT"] # broken: literal $PORT
CMD gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000} # shell form: expands
CMD ["sh", "-c", "exec gunicorn config.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
Or drop --bind and let Gunicorn read PORT itself, which works with the exec form. Gunicorn also reads GUNICORN_CMD_ARGS, another way to pass options without a shell.
4. Start fast enough
Platforms wait a limited time for the port to open (Heroku 60 seconds, others similar or configurable). Running migrate, collectstatic, or slow imports before Gunicorn starts can exceed it, and the platform reports "no open ports" even though the bind is correct. Move migrations to the platform's release or pre-deploy command, and collectstatic to the build step.
5. Match configured ports
- Fly.io:
internal_portin[http_service]must equal Gunicorn's port. - AWS App Runner: the service's configured port must match.
- Azure Container Apps / App Service: target port or
WEBSITES_PORTmust match. - Kubernetes-based platforms:
containerPortand ServicetargetPort.
Verification check: in the deploy logs, Gunicorn prints Listening at: http://0.0.0.0:<port>; the port must equal the platform's PORT or configured internal port, and the service should turn healthy within the boot window.
Explanation
PaaS platforms run your container behind their own router. They do not know which port your app uses unless you tell them, so most adopted the Heroku convention: the platform picks a port, exposes it as PORT, and expects the app to listen there on all interfaces. Health checks connect from outside the container's loopback interface. An app that binds to localhost or to its own favourite port is running but invisible, which is why the error is about ports rather than about Django.
Edge cases / notes
- ALLOWED_HOSTS must include the platform hostname (
.onrender.com,.up.railway.app,.fly.dev, the Cloud Run URL), or health checks get 400 responses. - Health check path. Some platforms let you set a health check path; make sure it does not redirect to HTTPS or require login.
- IPv6. Fly.io's private network uses IPv6; bind
[::]:8080if other apps connect over the private network. - Workers. On small instances, two or three Gunicorn workers avoid memory-related restarts that also look like port failures.
Next steps
- Platform guides: Deploy Django on Render, Railway, Fly.io, and Google Cloud Run.
FAQ
Why does binding to 127.0.0.1 fail in a container?
The platform connects over the container's network interface, not its loopback, so localhost-only servers are unreachable.
Does Gunicorn read the PORT variable automatically?
Yes, if you do not pass --bind, Gunicorn binds 0.0.0.0:$PORT when PORT is set.
Why does $PORT not expand in my Dockerfile CMD?
The exec form does not use a shell. Use the shell form or sh -c.
Can I run migrations in the start command?
It slows startup and can exceed the boot timeout. Use the platform's release or pre-deploy command.