Deploy a Django app
A Django app with gunicorn, PostgreSQL through connection pooling, static files, migrations before each deploy and a health check, step by step.
Updated 17 Oct 20264 min read
Asha keeps her shop's back office in a small Django project called office, in the repository asha/duka-office. This guide puts it live in project duka with its own PostgreSQL, in about fifteen minutes. We find Django from your manage.py, install your packages, and start it with gunicorn.
Step 1: list your packages
Our build installs only what your code lists, so put everything the app imports in requirements.txt:
Django>=5.2
gunicorn
psycopg[binary]
dj-database-url
whitenoiseWith gunicorn there, we start your app with gunicorn office.wsgi:application --bind 0.0.0.0:${PORT}, reading the module's name from manage.py. Without it, we'd use Django's own development server, which isn't made for visitors. If you use uv or Poetry instead, commit uv.lock or poetry.lock, and we install from that.
Name your Python version in a .python-version file at the top of the repository. We offer 3.10 to 3.14:
3.13Step 2: read your settings from variables
In office/settings.py, read everything that differs between your computer and Tanzahost from the environment:
import os
import dj_database_url
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = os.environ.get("DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = ["https://" + h for h in ALLOWED_HOSTS if h]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DATABASES = {"default": dj_database_url.config(default="sqlite:///db.sqlite3")}
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}Why each one matters here:
SECURE_PROXY_SSL_HEADER: visitors reach your app over HTTPS through our front door, which tells your app so inX-Forwarded-Proto. With this line, Django knows the visit was secure.DISABLE_SERVER_SIDE_CURSORS:DATABASE_URLgoes through connection pooling, one transaction at a time. Django's documentation says server-side cursors must be off with that kind of pool (Django: databases).The SQLite default only lets the build load your settings before a database is attached. Once one is,
DATABASE_URLwins.
Step 3: answer the health check
Before a new version gets visitors, we ask it for a page, on its private address, until it answers with a success. Django refuses that address, because it isn't in ALLOWED_HOSTS. Answer the check before Django's host check, on a path of its own. Make office/health.py:
from django.http import HttpResponse
class HealthCheck:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/healthz":
return HttpResponse("ok")
return self.get_response(request)Put it first in MIDDLEWARE, with WhiteNoise right after Django's security middleware:
MIDDLEWARE = [
"office.health.HealthCheck",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# … the rest, as before
]Commit, and push. Why this is needed: DisallowedHost.
Step 4: add the app
Open project duka. In the Add a service card, choose App.
Under Where's your code?, choose GitHub, pick
asha/duka-officeand its Branch, and leave Build every time I push on.We show what we found: Django on Python 3.13. Name the app
officereading today's pricesChoose Deploy.
This first deploy stops before going live, because SECRET_KEY isn't set yet. That's expected, and nothing is visible to visitors: steps 5 to 7 finish it.
Connecting GitHub needs our GitHub app, which isn't set up yet, and the GitHub tab says so. Meanwhile, download your repository as a ZIP and choose Upload a ZIP instead.
Step 5: add PostgreSQL
On the project's page, in Add a service, choose Database, then PostgreSQL. Name it postgres and tick office. The app gets these variables, managed by the database, and redeploys with them:
DATABASE_URL=postgres://db_duka:…@postgres.duka.internal:6432/db_duka
DATABASE_DIRECT_URL=postgres://db_duka:…@postgres.duka.internal:5432/db_dukawith the real password where the dots are. Port 6432 is the pool, which dj-database-url reads from DATABASE_URL. See PostgreSQL.
Step 6: set your variables
Make a secret key on your computer:
python -c "import secrets; print(secrets.token_urlsafe(50))"Open the project's Variables tab, choose office, and choose Paste a .env file:
SECRET_KEY=…
DEBUG=False
ALLOWED_HOSTS=office.duka.co.tz,office-duka-k2m9.tanzahost.coUse your app's own free address, shown on its page. Choose Add them, then Redeploy.
Step 7: build commands, migrations and the health address
Open the app. Under Build and run, set:
| Field | Value |
|---|---|
| Build command | python manage.py collectstatic --noinput |
| Before going live | python manage.py migrate --noinput |
| Health address | /healthz |
Choose Save and build. From now on, each deploy builds your static files, runs your migrations once in their own sandbox before visitors switch, and checks /healthz. If a migration fails, the deploy stops and the version before keeps serving.
Step 8: check it works
The deploy's page shows each step, then "Checking that it answers at /healthz", and goes live.
Open the free address. The admin's styles load, served by WhiteNoise and kept at our door for fast visits.
Open the project's Logs tab and choose
office: gunicorn's lines and each visit, with its status.
To make an admin user, open the app's terminal and run python manage.py createsuperuser. To put the app on your own domain, see Add a domain, and add the domain to ALLOWED_HOSTS too.
Workers and scheduled tasks
Celery or RQ runs as a worker, like celery -A office worker, with the same variables. Jobs on a timetable are scheduled tasks, like python manage.py clearsessions at 0 3 * * *, every day at 03:00 UTC.