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).

How the platform finds your appPlatform sets PORTe.g. 10000 or 8080Gunicorn bindsaddress and portHealth probeconnects from outsideFails if mismatchlocalhost, wrong portBind to 0.0.0.0 on the port the platform gives you, not a hard-coded localhost port.
  1. Bind to all interfaces: --bind 0.0.0.0:$PORT.
  2. Read the port from the environment, with a default: --bind 0.0.0.0:${PORT:-8000}.
  3. Use a shell form start command so $PORT expands (or rely on Gunicorn reading PORT itself).
  4. Make sure the app starts within the platform's boot timeout; move migrations to a pre-deploy step.
  5. Match any configured internal port (internal_port on 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.

what each platform expectsPort sourceBind Gunicorn toHerokuPORT env var✓0.0.0.0:$PORTRenderPORT (default 10000)✓0.0.0.0:$PORTRailwayPORT env var✓0.0.0.0:$PORTCloud RunPORT (default 8080)✓0.0.0.0:$PORTFly.iointernal_port in fly.toml✓0.0.0.0:<that port>One start command works everywhere if it reads PORT and binds 0.0.0.0django-deployment.com
Check current platform docs for defaults; the PORT variable is the stable contract.

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_port in [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_PORT must match.
  • Kubernetes-based platforms: containerPort and Service targetPort.
why the platform sees no open portCauseFixbound to 127.0.0.1bind 0.0.0.0 so the router can connecthard-coded 8000, platform uses PORTbind ${PORT:-8000} or omit --bind$PORT literal in exec-form CMDshell form or sh -c with execmigrate before Gunicorn startsmove to release or pre-deploy stepinternal_port differsmatch platform config to the bind portBind address, port value, and startup time are the three things to checkdjango-deployment.com
The deploy log usually shows which address and port Gunicorn actually used.

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 [::]:8080 if 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

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.

Keep going