Skip to content
Guides

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:

Terminal
Django>=5.2
gunicorn
psycopg[binary]
dj-database-url
whitenoise

With 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:

Terminal
3.13

Step 2: read your settings from variables

In office/settings.py, read everything that differs between your computer and Tanzahost from the environment:

python
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 in X-Forwarded-Proto. With this line, Django knows the visit was secure.

  • DISABLE_SERVER_SIDE_CURSORS: DATABASE_URL goes 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_URL wins.

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:

python
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:

python
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

  1. Open project duka. In the Add a service card, choose App.

  2. Under Where's your code?, choose GitHub, pick asha/duka-office and its Branch, and leave Build every time I push on.

  3. We show what we found: Django on Python 3.13. Name the app officereading today's prices

  4. Choose 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.

Until GitHub is switched on

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:

Terminal
DATABASE_URL=postgres://db_duka:…@postgres.duka.internal:6432/db_duka
DATABASE_DIRECT_URL=postgres://db_duka:…@postgres.duka.internal:5432/db_duka

with 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:

Terminal
python -c "import secrets; print(secrets.token_urlsafe(50))"

Open the project's Variables tab, choose office, and choose Paste a .env file:

Terminal
SECRET_KEY=…
DEBUG=False
ALLOWED_HOSTS=office.duka.co.tz,office-duka-k2m9.tanzahost.co

Use 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:

FieldValue
Build commandpython manage.py collectstatic --noinput
Before going livepython 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

  1. The deploy's page shows each step, then "Checking that it answers at /healthz", and goes live.

  2. Open the free address. The admin's styles load, served by WhiteNoise and kept at our door for fast visits.

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