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.
Quick answer
- Add
gunicorn,whitenoise,dj-database-url, andpsycopg[binary]torequirements.txt. - Add a
.python-versionfile so the Python version is pinned. - Create a
Procfilewith awebprocess (Gunicorn) and areleaseprocess (migrate). - Read
SECRET_KEY,DEBUG,ALLOWED_HOSTS, andDATABASE_URLfrom environment variables. - Add WhiteNoise middleware and set
STATIC_ROOT. heroku create, attach Postgres withheroku addons:create heroku-postgresql:essential-0, set config vars, thengit push heroku main.- 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 -
webis the process that receives HTTP traffic. Heroku setsPORTand Gunicorn binds to it automatically when it detects that variable.releaseruns 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 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
200with a long cache header from WhiteNoise /admin/loads with stylingcheck --deployshows 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
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_URLand 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.
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. collectstaticerrors: reproduce locally withDJANGO_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. WithSECURE_SSL_REDIRECT = Truebut no proxy header setting, Django sees every request as HTTP and redirects forever. - Hardcoding the
herokuapp.comhostname in settings. The hostname changes if you rename the app or move to a custom domain; keep it inDJANGO_ALLOWED_HOSTS. - Using
CONN_MAX_AGE = Nonewith 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
H12error when a request takes longer than 30 seconds. Move long work to a worker process. - Relying on
heroku run bashto 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
- Harden the rest of your configuration with the Django production settings checklist.
- Learn how WhiteNoise works in more depth in How to Serve Django Static Files with WhiteNoise.
- Compare Heroku with other platforms in Best Hosting Options for Django Deployment.
- Move uploads off the dyno with How to Store Django Media Files on Amazon S3.
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.