Deploy Django on Heroku with Postgres

Deploy a Django app to Heroku with Gunicorn, Heroku Postgres, WhiteNoise, a release-phase migration, and production-safe settings.

Problem statement

Heroku is still one of the fastest ways to get a Django project onto a public URL without managing a server. The platform builds your repository, runs it in dynos, and injects a DATABASE_URL when you attach Heroku Postgres.

The catch is that a Django project generated with startproject is not ready for that environment. It reads secrets from settings.py, uses SQLite, expects a local filesystem for static files, and has no process definition. Pushing it unchanged usually fails at the collectstatic step, or boots with DEBUG=True and a disposable SQLite file that disappears on the next dyno restart.

This guide takes a working Django project and makes it deployable on Heroku with a repeatable, production-safe configuration.

Heroku release flow for a Django appgit push herokucode to the platformBuildpip install,collectstaticRelease phasemanage.py migrateWeb dynogunicorn config.wsgiA failed build or release step stops the deploy; the previous release keeps serving.

Quick answer

  1. Add gunicorn, whitenoise, dj-database-url, and psycopg[binary] to requirements.txt.
  2. Add a .python-version file so the Python version is pinned.
  3. Create a Procfile with a web process (Gunicorn) and a release process (migrate).
  4. Read SECRET_KEY, DEBUG, ALLOWED_HOSTS, and DATABASE_URL from environment variables.
  5. Add WhiteNoise middleware and set STATIC_ROOT.
  6. heroku create, attach Postgres with heroku addons:create heroku-postgresql:essential-0, set config vars, then git push heroku main.
  7. Verify with heroku logs --tail, heroku run python manage.py check --deploy, and a real HTTPS request.

Step-by-step solution

1. Prepare the project layout

The examples assume this layout, where config is the project package that contains settings.py and wsgi.py:

myproject/
├── manage.py
├── config/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── requirements.txt
├── .python-version
└── Procfile

Replace config with your own project package name in every command below.

2. Pin dependencies and the Python version

Heroku detects a Python app from requirements.txt (or other supported package manager files) in the repository root. Keep versions pinned so the build on Heroku matches what you tested locally:

Django==5.2.6
gunicorn==23.0.0
whitenoise==6.9.0
dj-database-url==3.0.1
psycopg[binary]==3.2.10

Pin whatever versions you have actually tested; the numbers above are an example. Then pin the Python version with a .python-version file containing only the major and minor version:

3.13

Heroku's Python documentation recommends .python-version over relying on the default interpreter, because the default moves forward over time and a surprise interpreter upgrade on an unrelated deploy is hard to debug.

3. Create the Procfile

The Procfile lives in the repository root and declares process types:

release: python manage.py migrate --noinput
web: gunicorn config.wsgi --log-file -
two lines, two process typesrelease: python manage.py migrate --noinputweb: gunicorn config.wsgi--log-file -1231Runs once per deploy, before new dynos start; a failure cancels the release.2The only process type that receives HTTP traffic.3Logs to stdout, where heroku logs collects them.Gunicorn binds to $PORT automatically on Herokudjango-deployment.com
A worker line for Celery would be a third process type, scaled separately.
  • web is the process that receives HTTP traffic. Heroku sets PORT and Gunicorn binds to it automatically when it detects that variable.
  • release runs once per deploy, after the build and before new web dynos start. If the release command fails, the new release is not deployed and the previous one keeps serving traffic.

Keep migrations backward compatible with the currently running code, because old dynos keep serving requests while the release phase runs.

4. Make settings read from the environment

Update settings.py so nothing secret or environment-specific is hardcoded:

import os
from pathlib import Path

import dj_database_url

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"

ALLOWED_HOSTS = [
    host.strip()
    for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
    if host.strip()
]

CSRF_TRUSTED_ORIGINS = [
    origin.strip()
    for origin in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",")
    if origin.strip()
]

DATABASES = {
    "default": dj_database_url.config(
        env="DATABASE_URL",
        conn_max_age=600,
        conn_health_checks=True,
        ssl_require=True,
    )
}

Using os.environ["DJANGO_SECRET_KEY"] (square brackets) is deliberate: the app fails loudly at startup when the key is missing instead of silently running with a default.

Heroku terminates TLS at its router and forwards requests to your dyno over HTTP with an X-Forwarded-Proto header. Tell Django to trust it and enable the HTTPS-related settings:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG

Only set SECURE_PROXY_SSL_HEADER when a proxy you control (here, the Heroku router) always sets or overwrites that header.

5. Serve static files with WhiteNoise

Heroku runs python manage.py collectstatic --noinput automatically during the build, and the build fails if that step fails. Configure STATIC_ROOT and WhiteNoise so the collected files are served efficiently by the web dyno:

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # ... the rest of your middleware
]

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}
WhiteNoise goes secondSecurityMiddlewareHTTPS redirect and headers firstWhiteNoiseMiddlewareanswers /static/ and stopsSessionMiddlewareskipped for static filesCommonMiddleware, CSRF, auth…only for real page requestsPlaced lower, every static request pays for sessions and authdjango-deployment.com
CompressedManifestStaticFilesStorage adds hashed names so files can be cached forever.

WhiteNoise must come directly after SecurityMiddleware. Run python manage.py collectstatic --noinput locally once before pushing; if it fails on your machine it will fail the Heroku build too.

User uploads are a different problem. The dyno filesystem is ephemeral and is reset on every restart and deploy, so MEDIA_ROOT on local disk will lose files. Store uploads in object storage such as S3 or Cloudflare R2 instead.

6. Create the app and attach Postgres

Log in and create the app from the repository root:

heroku login
heroku create my-django-app

heroku create adds a heroku Git remote. Attach a Postgres database:

heroku addons:create heroku-postgresql:essential-0 -a my-django-app
heroku pg:wait -a my-django-app

The add-on sets the DATABASE_URL config var on the app. Confirm it:

heroku config -a my-django-app

7. Set configuration variables

Generate a strong secret key locally and set the remaining config vars:

python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

heroku config:set -a my-django-app \
  DJANGO_SECRET_KEY='paste-the-generated-key' \
  DJANGO_DEBUG=False \
  DJANGO_ALLOWED_HOSTS='my-django-app-1234abcd.herokuapp.com,www.example.com' \
  DJANGO_CSRF_TRUSTED_ORIGINS='https://my-django-app-1234abcd.herokuapp.com,https://www.example.com'

Use the exact hostname that heroku create printed, because newer apps get a generated suffix in their herokuapp.com domain. Every config var change restarts the dynos with a new release.

8. Deploy

Commit and push the branch Heroku should build:

git add Procfile .python-version requirements.txt config/settings.py
git commit -m "Configure Django for Heroku"
git push heroku main

Watch the build output for three things: the Python version that was installed, the collectstatic step, and the release phase running migrate.

9. Create a superuser and verify

heroku run python manage.py createsuperuser -a my-django-app
heroku run python manage.py check --deploy -a my-django-app
heroku logs --tail -a my-django-app

Then check the live site:

curl -I https://my-django-app-1234abcd.herokuapp.com/
curl -I https://my-django-app-1234abcd.herokuapp.com/static/admin/css/base.css

Verification check:

  • the home page returns 200 (or your expected redirect)
  • a static file returns 200 with a long cache header from WhiteNoise
  • /admin/ loads with styling
  • check --deploy shows no warnings you have not consciously accepted

10. Add a custom domain

heroku domains:add www.example.com -a my-django-app
heroku domains -a my-django-app

Point a CNAME record at the DNS target that heroku domains shows. Heroku's Automated Certificate Management provisions TLS for custom domains on paid dynos. Make sure the domain is in DJANGO_ALLOWED_HOSTS and DJANGO_CSRF_TRUSTED_ORIGINS before you switch DNS.

Rollback

Every deploy and config change creates a numbered release. If a deploy breaks the site:

heroku releases -a my-django-app
heroku rollback v42 -a my-django-app
a rollback is a new releasev40 deployknown goodv41 deploymigration ranErrors spikebad code in v41rollback v40creates v42v42 servingv40 code, v41 schemaCode and config roll back; the database schema does notdjango-deployment.com
Backward-compatible migrations are what let the v40 code run on the v41 schema.

heroku rollback restores the code and config of the earlier release, but it does not roll back the database. If the broken release ran a destructive migration, you need a database backup or a forward fix. Take a manual backup before risky migrations:

heroku pg:backups:capture -a my-django-app

Explanation

Heroku's model maps cleanly to the twelve-factor pattern Django deployments benefit from anyway:

  • Config in the environment. DATABASE_URL and your own config vars replace hardcoded settings, so the same code runs locally, in staging, and in production.
  • Build, release, run. The build installs dependencies and collects static files; the release phase applies migrations; dynos run the immutable result.
  • Disposable processes. Dynos restart at least daily and on every deploy, so nothing important may live on local disk.

WhiteNoise fits this model because the static files are baked into the slug during the build and served from the same process, with far-future cache headers made safe by hashed file names.

Edge cases / notes

Build fails at collectstatic

Run python manage.py collectstatic --noinput locally with DJANGO_DEBUG=False. A missing file referenced from CSS (common with CompressedManifestStaticFilesStorage) or a setting that requires an environment variable at import time are the usual causes. As a last resort you can heroku config:set DISABLE_COLLECTSTATIC=1, but then you must collect static files some other way.

H10 App crashed or H14 no web processes

H14 means no web dynos are running: check the Procfile name and scale with heroku ps:scale web=1. H10 means the web process exited: read heroku logs --tail for the Python traceback, often a missing config var such as DJANGO_SECRET_KEY.

Release phase migration timeouts

Long-running migrations block the deploy. Split large data migrations into management commands you run separately with heroku run, and keep the release-phase migration fast.

Background workers

Add a second process type such as worker: celery -A config worker --loglevel=info to the Procfile, provision a Redis add-on, and scale it with heroku ps:scale worker=1.

Troubleshooting a failing Heroku deploy

When a push fails or the app crashes after release, work through the stages in order, because each stage has its own logs.

the H and R codes, decodedCodeUsual causeH10 App crashedDjango failed at start:import or setting errorH14 No web dynosweb process scaled to 0or missing from ProcfileH12 Request timeoutview took over 30 s;move work to a workerR14 Memory quotatoo many Gunicorn workersfor the dyno sizeheroku logs --tail shows the code next to the request that hit itdjango-deployment.com
Set WEB_CONCURRENCY to control the Gunicorn worker count per dyno.

Build stage. The build log is printed by git push and is also available in the dashboard under the app's Activity tab. Typical failures:

  • No default language could be detected: requirements.txt (or another supported dependency file) is not in the repository root.
  • A package fails to compile: a dependency without a wheel for the Heroku stack. Use the binary variant (psycopg[binary]) or a version that publishes wheels.
  • collectstatic errors: reproduce locally with DJANGO_DEBUG=False python manage.py collectstatic --noinput. Settings that read a required environment variable at import time will fail here too, because config vars are available during the build but a missing one is still missing.

Release stage. If release: python manage.py migrate --noinput fails, the deploy stops and the previous release keeps running. Read the output with:

heroku releases -a my-django-app
heroku releases:output v43 -a my-django-app

Fix the migration and push again. Do not "fix" it by removing the release command, which would deploy code that expects a schema the database does not have.

Run stage. Crashes after release show up as H10 errors in the router log. The Python traceback appears just above:

heroku logs --tail -a my-django-app --source app
heroku ps -a my-django-app
heroku run python manage.py check --deploy -a my-django-app

heroku run starts a one-off dyno with the same config vars, which is the fastest way to reproduce import and settings errors interactively.

Common mistakes

  • Forgetting SECURE_PROXY_SSL_HEADER. With SECURE_SSL_REDIRECT = True but no proxy header setting, Django sees every request as HTTP and redirects forever.
  • Hardcoding the herokuapp.com hostname in settings. The hostname changes if you rename the app or move to a custom domain; keep it in DJANGO_ALLOWED_HOSTS.
  • Using CONN_MAX_AGE = None with many dynos. Unlimited persistent connections from every dyno can exhaust the connection limit of smaller Postgres plans. Keep a finite value and check the plan's connection limit.
  • Running long tasks in web dynos. The router returns an H12 error when a request takes longer than 30 seconds. Move long work to a worker process.
  • Relying on heroku run bash to edit files. One-off dynos have their own ephemeral filesystem; changes never reach running web dynos.

Sizing web dynos

Gunicorn reads the WEB_CONCURRENCY environment variable as its default worker count. Set it explicitly based on dyno memory, then watch memory in metrics or logs (R14 errors mean the dyno exceeded its memory quota):

heroku config:set WEB_CONCURRENCY=3 -a my-django-app

Start with two or three workers on small dynos and increase only while memory stays below the quota under realistic load.

Next steps

FAQ

Does Heroku run Django migrations automatically?

No. Heroku only runs migrations if you define them, and the recommended place is a release process in the Procfile. It runs once per deploy before new web dynos start receiving traffic.

Why do uploaded files disappear on Heroku?

Dynos have an ephemeral filesystem that is discarded when a dyno restarts or a new release is deployed. Store user uploads in object storage such as Amazon S3 or Cloudflare R2 using django-storages.

Do I need Nginx in front of Django on Heroku?

No. The Heroku router terminates TLS and forwards requests to Gunicorn in your web dyno. WhiteNoise serves static files from the same process, so no separate reverse proxy is required.

How do I set the Python version for a Django app on Heroku?

Add a .python-version file to the repository root containing a version such as 3.13. Pinning it prevents the default interpreter from changing underneath you on a later deploy.

Can I use SQLite on Heroku?

Not for anything persistent. The SQLite file lives on the ephemeral dyno filesystem and each dyno has its own copy, so data is lost on restart and not shared across dynos. Use Heroku Postgres or another managed database.

Keep going