Skip to content

Deploying to Render

Render is a popular platform for deploying web services.

  • a requirements.txt
  • a WSGI entrypoint (wsgi.py)
  • a start command (Gunicorn)
text
gunicorn "wsgi:app" --bind 0.0.0.0:$PORT

Set in Render dashboard:

  • SECRET_KEY
  • DATABASE_URL (if using managed DB)

If your app serves lots of static assets, consider:

  • platform static hosting
  • or put a CDN in front
  • debug is off
  • migrations run
  • logs show requests responding
  • health endpoint returns 200

Platforms differ in their dashboards and almost not at all in what they require. Learn the four things below once, and each host becomes a matter of where you type them:

diagram Diagram mermaid
build command
pip install -r requirements.txt
start command
gunicorn app:app --bind 0.0.0.0:$PORT

$PORT is the part people get wrong. The platform chooses a port and passes it in; an app hard-coded to 8000 will not receive traffic and the deploy will be marked unhealthy for a reason the logs make look unrelated.

app:app means “the variable app in app.py”. With an application factory it is different:

with a factory
gunicorn "myapp:create_app()" --bind 0.0.0.0:$PORT

Locally, that target can be checked without any platform at all:

verify_entrypoint.py
import importlib
mod, _, attr = "app:app".partition(":")
wsgi = getattr(importlib.import_module(mod), attr)
assert callable(wsgi)          # a WSGI application is a callable

Measured on the demo app: the target resolved to a Flask object, callable was True, and serving it returned 200. If that assertion fails locally, no amount of dashboard configuration will help.

Set SECRET_KEY, DATABASE_URL and anything else as environment variables in the dashboard, not in a committed file. This is exactly the .env model with a different place to type it.

Two constraints worth planning around on any platform-as-a-service:

  • The filesystem is ephemeral. Anything written to disk disappears on the next deploy or restart. A SQLite file in instance/ works until the first redeploy, then silently resets. Use a managed database, and object storage for uploads.
  • Free tiers sleep. An idle service is suspended and the next request pays the cold start — tens of seconds. Fine for a demo, not for anything with users waiting.
checkwhy
requirements.txt pins exact versionsan unpinned dependency can change under you between deploys
gunicorn is in itit is a dependency of the deployment, not of your laptop
the start command binds $PORTa hard-coded port receives no traffic
debug is offthe interactive debugger is remote code execution
SECRET_KEY comes from the environmenta committed key is a forged session for anyone
the database is not SQLite on local diskit disappears on redeploy
sketch Why the platform port matters p5.js
The platform assigns a port and expects your server to bind it. Hard-coding one means the health check never succeeds.
pch.quizTag pch.quizDefaultTitle
  1. Why must the start command bind to $PORT rather than a fixed port?

    pch.quizShowAnswer

    B — the platform assigns the port and connects its health check to it; a hard-coded port receives no traffic — The confusing part is that gunicorn reports a successful start, so the logs look healthy while the platform cannot reach it.

  2. Why is a SQLite file in instance/ a poor choice on a platform-as-a-service?

    pch.quizShowAnswer

    B — the filesystem is ephemeral, so the database disappears on the next deploy or restart — It works right up until the first redeploy, then silently resets. Use a managed database, and object storage for uploads.

  3. With an application factory, what should the gunicorn target be?

    pch.quizShowAnswer

    B — myapp:create_app() — gunicorn accepts a call expression, so the factory runs and returns the app. Verify the target locally: import the module, get the attribute and assert it is callable.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading