Deploy Django on DigitalOcean App Platform

Deploy Django on DigitalOcean App Platform with an app spec, Gunicorn, managed PostgreSQL, bindable environment variables, a pre-deploy migration job, and WhiteNoise.

Problem statement

DigitalOcean App Platform is a managed platform: you connect a repository, and it builds the app with a buildpack or Dockerfile, runs it behind HTTPS, and scales it horizontally. It removes server administration, which makes it attractive for Django apps that would otherwise run on a single Droplet maintained by hand.

App Platform runs containers whose filesystem is not persistent, sets the listening port through an environment variable, and needs to be told how to build, migrate, and start the app. Doing that through the dashboard alone produces an app nobody can recreate. This guide defines the whole deployment in an app spec committed to the repository.

Django on App PlatformGit pushdeploy on pushBuildbuildpack imagePre-deploy jobmigrateWeb serviceGunicorn on $PORTA failed pre-deploy job stops the release, so the old version keeps serving traffic.
  1. Add gunicorn, whitenoise, psycopg, and dj-database-url to requirements.txt.
  2. Read SECRET_KEY, DATABASE_URL, and ALLOWED_HOSTS from environment variables.
  3. Serve static files with WhiteNoise and run collectstatic in the build command.
  4. Write .do/app.yaml with a web service, a PRE_DEPLOY migration job, and a managed PostgreSQL database.
  5. Bind DATABASE_URL from the database component and mark secrets as SECRET.
  6. Create the app with doctl apps create --spec .do/app.yaml, then add your domain.

Step-by-step solution

1. Prepare Django settings

# config/settings/production.py
import os
import dj_database_url

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

DATABASES = {"default": dj_database_url.config(conn_max_age=60, ssl_require=True)}

MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

MIDDLEWARE.insert(1, ...) assumes SecurityMiddleware is first in your base settings; WhiteNoise must come directly after it. Managed databases require TLS, hence ssl_require=True.

2. Write the app spec

# .do/app.yaml
name: myapp
region: fra
services:
  - name: web
    github:
      repo: example/myapp
      branch: main
      deploy_on_push: true
    environment_slug: python
    build_command: python manage.py collectstatic --noinput
    run_command: gunicorn config.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --access-logfile -
    http_port: 8080
    instance_size_slug: apps-s-1vcpu-1gb
    instance_count: 1
    health_check:
      http_path: /healthz/
    envs:
      - key: DJANGO_SETTINGS_MODULE
        value: config.settings.production
        scope: RUN_AND_BUILD_TIME
      - key: DJANGO_SECRET_KEY
        value: replace-in-dashboard
        type: SECRET
        scope: RUN_AND_BUILD_TIME
      - key: DJANGO_ALLOWED_HOSTS
        value: ${APP_DOMAIN},example.com
        scope: RUN_AND_BUILD_TIME
      - key: DATABASE_URL
        value: ${db.DATABASE_URL}
        scope: RUN_TIME
jobs:
  - name: migrate
    kind: PRE_DEPLOY
    github:
      repo: example/myapp
      branch: main
    environment_slug: python
    run_command: python manage.py migrate --noinput
    instance_size_slug: apps-s-1vcpu-1gb
    envs:
      - key: DJANGO_SETTINGS_MODULE
        value: config.settings.production
      - key: DJANGO_SECRET_KEY
        value: replace-in-dashboard
        type: SECRET
      - key: DJANGO_ALLOWED_HOSTS
        value: ${APP_DOMAIN}
      - key: DATABASE_URL
        value: ${db.DATABASE_URL}
databases:
  - name: db
    engine: PG
    production: true
    cluster_name: myapp-db

Key points:

  • $PORT is set by App Platform; binding Gunicorn to it (and matching http_port) is what connects the router to your app.
  • ${db.DATABASE_URL} is a bindable variable resolved from the db database component. ${APP_DOMAIN} resolves to the app's primary domain.
  • The PRE_DEPLOY job runs after the build and before new web instances receive traffic. If it fails, the deploy fails and the previous version keeps running.
  • production: true with cluster_name attaches an existing managed database cluster. Create it first (doctl databases create myapp-db --engine pg --region fra1 --size db-s-1vcpu-1gb) so its lifetime is independent of the app. Check doctl apps tier instance-size list for current instance size slugs.
the app spec lines that matterbuild_command: python manage.pycollectstatic--noinputrun_command: gunicorn config.wsgi:application --bind 0.0.0.0:$PORThealth_check:http_path:/healthz/- key: DJANGO_SECRET_KEYtype:SECRETkind:PRE_DEPLOY123451Runs at build time, so it must not need the database.2Bind to the port the platform assigns.3A cheap view; a failing check blocks the deploy.4Encrypted, and hidden from the spec after upload.5The migrate job runs before new instances take traffic.The spec lives in the repository, so the whole app is reviewabledjango-deployment.com
Replace placeholder secret values in the dashboard, never in the committed file.

The build step runs collectstatic, so it needs every setting that import-time code reads. If production settings require runtime-only secrets, use a lightweight build settings module for that command.

3. Add a health endpoint

# config/urls.py
from django.http import HttpResponse
from django.urls import path

urlpatterns += [path("healthz/", lambda request: HttpResponse("ok"))]

Health checks arrive over plain HTTP from the platform. Exempt the path from the HTTPS redirect with SECURE_REDIRECT_EXEMPT = [r"^healthz/$"] if checks fail with 301.

4. Create the app

doctl apps spec validate .do/app.yaml
doctl apps create --spec .do/app.yaml
doctl apps list

Then open the app in the dashboard and replace the placeholder secret values, or supply real values in the spec from a secure CI context. Secret values are encrypted once stored; exporting the spec later shows encrypted values rather than plain text.

5. Add a custom domain

Add the domain in the app's settings (or under domains: in the spec). App Platform gives you a CNAME target or manages DNS if the domain uses DigitalOcean nameservers, and issues the certificate automatically. Add the domain to DJANGO_ALLOWED_HOSTS.

6. Deploy changes

With deploy_on_push: true, pushing to main triggers a build. Spec changes (new env vars, instance sizes) are applied with:

doctl apps update <app-id> --spec .do/app.yaml
from git push to livePush to maindeploy_on_pushBuild imagebuildpack + collectstaticPRE_DEPLOY jobmigrateHealth check passes/healthz/Traffic switchesold instance stopsA failure at any step leaves the previous deployment servingdjango-deployment.com
A migration that succeeds before a failed health check stays applied, so keep migrations backward compatible.

Verification check:

doctl apps list-deployments <app-id>
doctl apps logs <app-id> web --type run
curl -sI https://example.com/ | head -1

Check the migrate job's logs with doctl apps logs <app-id> migrate --type deploy when a deploy fails.

Explanation

The app spec is the single source of truth for components, commands, environment, and database bindings. App Platform builds an image, runs the pre-deploy job against the production database, and only then replaces the running instances. Because instances are replaceable and their disks ephemeral, static files are compiled into the image and served by WhiteNoise, and uploads must go to object storage.

Edge cases / notes

Media uploads

uploads need somewhere permanentContainer disk✕wiped on every deploy and restart✕not shared between instancesfine for temporary files onlySpaces (S3-compatible)✓survives deploys✓every instance sees the same files✓django-storages with the S3 backendThe first redeploy after launch is when local uploads vanishdjango-deployment.com
Keep private files in a private bucket and serve them with signed URLs.

Use DigitalOcean Spaces (S3-compatible) with django-storages' S3 backend and the region endpoint (for example https://fra1.digitaloceanspaces.com). Local uploads are lost on every deploy.

Workers and scheduled jobs

Add Celery as a workers: component with its own run command. Scheduled jobs can use kind: SCHEDULED job components with a cron expression, if available on your plan, or Celery Beat running as a single worker.

Rollback

Redeploy a previous deployment from the dashboard or with doctl apps create-deployment after reverting the commit. Keep migrations backward compatible so the old code works against the new schema.

When to automate this

The spec already captures infrastructure. The next step is a CI workflow that runs tests, validates the spec, and deploys only after checks pass, instead of deploying every push directly.

Next steps

FAQ

What port does Django need on DigitalOcean App Platform?

Bind Gunicorn to 0.0.0.0:$PORT and set http_port to the same value (8080 by default). A mismatch makes health checks fail and the deploy roll back.

How do I run migrations on App Platform?

Use a job component with kind: PRE_DEPLOY that runs python manage.py migrate --noinput. It runs once per deploy before traffic switches.

Why does collectstatic fail during the App Platform build?

Build-time commands only see variables scoped to build time. Give the required variables RUN_AND_BUILD_TIME scope or use a build settings module that does not need runtime secrets.

Can App Platform store uploaded files?

Not persistently. Use Spaces or another object store for media.

Keep going