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.
- Add
gunicorn,whitenoise,psycopg, anddj-database-urltorequirements.txt. - Read
SECRET_KEY,DATABASE_URL, andALLOWED_HOSTSfrom environment variables. - Serve static files with WhiteNoise and run
collectstaticin the build command. - Write
.do/app.yamlwith a web service, aPRE_DEPLOYmigration job, and a managed PostgreSQL database. - Bind
DATABASE_URLfrom the database component and mark secrets asSECRET. - 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:
$PORTis set by App Platform; binding Gunicorn to it (and matchinghttp_port) is what connects the router to your app.${db.DATABASE_URL}is a bindable variable resolved from thedbdatabase component.${APP_DOMAIN}resolves to the app's primary domain.- The
PRE_DEPLOYjob runs after the build and before new web instances receive traffic. If it fails, the deploy fails and the previous version keeps running. production: truewithcluster_nameattaches 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. Checkdoctl apps tier instance-size listfor current instance size slugs.
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
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
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
- Compare with a self-managed server in How to Deploy Django on a DigitalOcean Droplet.
- Configure static file serving with How to Serve Django Static Files with WhiteNoise.
- Structure settings with Configure Django Environment Variables for Production.
- If the pre-deploy job fails, use Django Migration Failed During Deploy: Recovery Playbook.
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.