Deploy Django on Azure App Service

Deploy Django to Azure App Service on Linux with Oryx builds, Gunicorn startup commands, Azure Database for PostgreSQL, and secure app settings.

Problem statement

Azure App Service runs Python apps in a managed Linux container: you push code, the Oryx build system installs dependencies, and the platform starts Gunicorn for you. That convenience hides several decisions that matter in production:

  • which file App Service uses to find your WSGI application
  • whether dependencies are built on the server or shipped with your code
  • where collectstatic runs and how static files are served
  • how HTTPS reaches Django when TLS terminates in front of your container
  • where secrets and database credentials live

If any of those are wrong you get the default "Your web app is running and waiting for your content" page, a ModuleNotFoundError, unstyled admin pages, or an app quietly running on SQLite. This guide deploys a Django project to App Service on Linux with Azure Database for PostgreSQL and explains each platform behavior as you hit it.

Django on Azure App ServiceDeployzip or Git pushOryx buildpip, collectstaticStartup commandgunicorn config.wsgiApp settingsinjected as env varsBuild and runtime happen in separate containers, so logs live in two places.

Quick answer

  1. Put requirements.txt (with gunicorn, whitenoise, and psycopg[binary]) in the repository root, next to manage.py.
  2. Read SECRET_KEY, DEBUG, database settings, and ALLOWED_HOSTS from environment variables; use WEBSITE_HOSTNAME for the default host.
  3. Add WhiteNoise and set STATIC_ROOT; Oryx runs collectstatic during the build when it finds manage.py.
  4. Create a resource group, an App Service plan, a Linux web app, and a PostgreSQL Flexible Server.
  5. Set app settings, including SCM_DO_BUILD_DURING_DEPLOYMENT=1.
  6. Set an explicit Gunicorn startup command with az webapp config set --startup-file.
  7. Deploy, run migrations over SSH or a post-build step, and verify with the log stream.

Step-by-step solution

1. Check the project layout

App Service's Django detection looks for a wsgi.py file and starts Gunicorn against the folder that contains it. Dependency installation only happens if the dependency file is in the project root. Use a layout like this:

.
├── manage.py
├── requirements.txt
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── startup.sh          # optional

Do not commit or deploy a virtual environment directory. Virtual environments are not portable between machines, and a deployed .venv is a common cause of ModuleNotFoundError on App Service.

2. Prepare requirements

Django==5.2.6
gunicorn==23.0.0
whitenoise==6.9.0
psycopg[binary]==3.2.10

Pin the versions you have tested. If your project uses pyproject.toml with uv.lock, Oryx builds with uv instead; if pyproject.toml exists without uv.lock, it falls back to Poetry. Keep only one dependency definition in the root to avoid surprises.

3. Configure settings for App Service

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"

ALLOWED_HOSTS = []
if "WEBSITE_HOSTNAME" in os.environ:
    ALLOWED_HOSTS.append(os.environ["WEBSITE_HOSTNAME"])
ALLOWED_HOSTS += [
    h.strip() for h in os.environ.get("DJANGO_EXTRA_HOSTS", "").split(",") if h.strip()
]
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": os.environ["DB_HOST"],
        "PORT": os.environ.get("DB_PORT", "5432"),
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {"sslmode": "require"},
    }
}

App Service sets WEBSITE_HOSTNAME to the app's default domain, such as my-django-app.azurewebsites.net, so you do not need to hardcode it. Add custom domains through DJANGO_EXTRA_HOSTS.

settings that read from App ServiceSECRET_KEY = os.environ["DJANGO_SECRET_KEY"]ALLOWED_HOSTS.append(os.environ["WEBSITE_HOSTNAME"])CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS]SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")12341From an app setting, or a Key Vault reference.2Set by App Service to the default azurewebsites.net host.3Custom domains must be added here too, or forms fail CSRF.4TLS ends at the App Service front end.The same file runs locally, with DJANGO_EXTRA_HOSTS adding other namesdjango-deployment.com
App settings reach Django as ordinary environment variables.

TLS terminates at the App Service front end, and requests reach your container as plain HTTP with an X-Forwarded-Proto header. Configure Django to trust it:

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

Enable HTTPS-only on the web app (step 7) instead of relying only on SECURE_SSL_REDIRECT, so the redirect happens before the request reaches your container.

4. Configure static files

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

When build automation is enabled and Oryx finds manage.py in the root, it runs collectstatic during the build. If that fails, check the deployment logs rather than the runtime log stream, because the build runs in a separate container.

5. Create the Azure resources

az login

az group create --name django-rg --location westeurope

az appservice plan create \
  --name django-plan \
  --resource-group django-rg \
  --sku B1 \
  --is-linux

az webapp create \
  --name my-django-app \
  --resource-group django-rg \
  --plan django-plan \
  --runtime "PYTHON:3.13"
what gets created, from the outside inResource groupdelete it to remove everythingApp Service plan (Linux)the VM size you pay forWeb app, Python runtimeyour code, app settings, startupPostgreSQL Flexible ServerSSL required, firewall rulesScaling the plan scales every app that shares itdjango-deployment.com
Keep production in its own resource group so a cleanup cannot remove it by accident.

List the runtime strings your CLI supports with az webapp list-runtimes --os linux and pick one that matches the Python version you test with. The app name becomes part of the default hostname, so it must be globally unique.

Create a PostgreSQL Flexible Server and a database:

az postgres flexible-server create \
  --resource-group django-rg \
  --name my-django-db \
  --location westeurope \
  --tier Burstable \
  --sku-name Standard_B1ms \
  --admin-user djangoadmin \
  --admin-password 'use-a-generated-password' \
  --version 16 \
  --public-access 0.0.0.0

az postgres flexible-server db create \
  --resource-group django-rg \
  --server-name my-django-db \
  --database-name django

--public-access 0.0.0.0 creates a firewall rule that allows connections from Azure services. It is convenient, but it allows any Azure-hosted resource to attempt a connection, so the password and TLS requirement matter. For stricter isolation, use virtual network integration with a private server instead. Run az postgres flexible-server create --help to confirm the options available in your CLI version before scripting this.

6. Set app settings

App settings are exposed to your code as environment variables:

az webapp config appsettings set \
  --resource-group django-rg \
  --name my-django-app \
  --settings \
    SCM_DO_BUILD_DURING_DEPLOYMENT=1 \
    DJANGO_SECRET_KEY='generated-secret' \
    DJANGO_DEBUG=0 \
    DB_HOST=my-django-db.postgres.database.azure.com \
    DB_NAME=django \
    DB_USER=djangoadmin \
    DB_PASSWORD='use-a-generated-password'

SCM_DO_BUILD_DURING_DEPLOYMENT=1 makes Oryx create the virtual environment and install dependencies on deploy. For secrets you rotate regularly, store them in Azure Key Vault and use Key Vault references in app settings rather than plain values.

7. Set the startup command and HTTPS-only

Without a custom command, App Service runs Gunicorn with --bind=0.0.0.0 --timeout 600 against the detected module. Set an explicit command so worker count, timeout, and logging are under your control:

az webapp config set \
  --resource-group django-rg \
  --name my-django-app \
  --startup-file "gunicorn --bind=0.0.0.0 --timeout 120 --workers 3 --access-logfile '-' --error-logfile '-' config.wsgi"

az webapp update \
  --resource-group django-rg \
  --name my-django-app \
  --https-only true

A 600-second default timeout lets a stuck request hold a worker for ten minutes. Choose a timeout that fits your slowest legitimate request instead.

App Service ignores errors in a custom startup command and falls back to its own detection. If your change seems to have no effect, check the log stream for the actual Gunicorn command that ran.

8. Deploy the code

For a first deploy from a local checkout, zip deployment is simple:

git archive --format zip --output app.zip HEAD
az webapp deploy \
  --resource-group django-rg \
  --name my-django-app \
  --src-path app.zip \
  --type zip
from zip to serving requests1Upload the codeaz webapp up or zip deploy2Oryx buildinstalls requirements.txt3Startup commandgunicorn on port 80004Migrate over SSHonce per release5Verify the sitelog stream, adminThe build only runs when SCM_DO_BUILD_DURING_DEPLOYMENT is truedjango-deployment.com
A missing build step shows up as ModuleNotFoundError for every installed package.

git archive only includes committed files, which keeps local .env files and virtual environments out of the package. For ongoing deploys, connect GitHub Actions through the Deployment Center.

9. Run migrations and create a superuser

Open an SSH session into the running container:

az webapp ssh --resource-group django-rg --name my-django-app

Inside the session, the build output is served from a temporary directory exposed as APP_PATH, and Oryx's virtual environment must be activated:

cd $APP_PATH
source antenv/bin/activate
python manage.py migrate --noinput
python manage.py createsuperuser

The virtual environment directory name depends on the Oryx configuration; if antenv does not exist, list the directory to find it. For automated deploys, run migrations as a CI step before the new code starts, or with a POST_BUILD_COMMAND if you accept that the build container needs database access.

10. Verify

az webapp log config \
  --resource-group django-rg \
  --name my-django-app \
  --docker-container-logging filesystem

az webapp log tail --resource-group django-rg --name my-django-app

curl -I https://my-django-app.azurewebsites.net/
curl -I https://my-django-app.azurewebsites.net/static/admin/css/base.css

Verification check:

  • the log stream shows your Gunicorn command and workers booting
  • / returns your app, not the default App Service page
  • the static file returns 200
  • http:// requests redirect to https://
  • python manage.py check --deploy inside SSH shows no unexpected warnings

Rollback

App Service does not keep numbered releases the way some platforms do. Practical rollback options are:

  • redeploy the previous commit's zip package
  • use deployment slots: deploy to a staging slot, verify, then az webapp deployment slot swap; swapping back is your rollback
  • restore the database with Flexible Server point-in-time restore if a migration damaged data

Deployment slots require a Standard tier plan or higher.

Explanation

App Service splits the deploy into a build container (Oryx) and a runtime container. Oryx installs dependencies and runs collectstatic; the runtime container starts Gunicorn behind the platform's own reverse proxy. That is why build errors appear in deployment logs, runtime errors appear in the log stream, and files written outside /home disappear on restart.

Because the platform terminates TLS and proxies requests, Django only knows a request was HTTPS through X-Forwarded-Proto. Without SECURE_PROXY_SSL_HEADER, secure cookies, CSRF origin checks, and request.is_secure() all behave as if the site were plain HTTP.

Edge cases / notes

You still see the default welcome page

App Service could not find your app. Confirm wsgi.py exists, requirements.txt is in the root, and SCM_DO_BUILD_DURING_DEPLOYMENT is 1, then redeploy.

Fatal SSL connection is required

Azure Database for PostgreSQL requires TLS by default. Keep sslmode=require in OPTIONS, and use verify-full with the Azure root certificate if you need server identity verification.

Media uploads

Local files outside /home do not persist across restarts, and /home is shared storage that is slow for heavy use. Store user uploads in Azure Blob Storage through django-storages.

Scaling out

With multiple instances, anything stored in local memory (sessions in locmem cache, rate limit counters) is per instance. Use database or Redis-backed sessions and caches.

Troubleshooting checklist

App Service problems fall into three places, and each has its own log source:

SymptomWhere to lookUsual cause
Deployment failsDeployment Center logs (Oryx build)dependency file not in root, package build failure, collectstatic error
Default welcome pageSSH into the container, check $APP_PATHbuild automation disabled, app files not deployed, wsgi.py not found
"Application Error" or 503Log streamexception at import time, wrong module in startup command, missing app setting
Unstyled adminbrowser network tabWhiteNoise middleware missing or collectstatic skipped
400 Bad RequestLog stream with DisallowedHostcustom domain missing from allowed hosts
symptom to cause on App ServiceSymptomUsual causeDefault welcome pagestartup command orwsgi module not foundContainer did not respond on 8000Gunicorn bound elsewhere, or crashed400 Bad Requesthost missing from ALLOWED_HOSTSSSL connection is requiredsslmode not set for Flexible ServerStatic files 404collectstatic orWhiteNoise not configuredThe log stream shows the container start-up and the Django tracebackdjango-deployment.com
Enable application logging to the file system first, or the log stream stays empty.

A quick way to reproduce import problems without waiting for a restart is to run the same command the startup uses inside SSH:

az webapp ssh --resource-group django-rg --name my-django-app
# inside the container
cd $APP_PATH
source antenv/bin/activate
python -c "import config.wsgi"
python manage.py check --deploy

If import config.wsgi fails, the traceback is the real error that the log stream may have truncated.

Common mistakes

  • Deploying a local virtual environment. A .venv built on macOS or Windows contains incompatible binaries. Exclude it from the package and let Oryx build.
  • Setting DEBUG to the string "False" and reading it with bool(). Any non-empty string is truthy. Compare explicitly, as in the settings above.
  • Scaling out with SQLite or local media. Each instance has its own view of anything outside shared storage, and SQLite on shared storage locks frequently.
  • Forgetting app settings on deployment slots. Slots have their own configuration. Mark settings that must not move during a swap (such as a staging database URL) as deployment slot settings.
  • Keeping the 600-second default timeout. Long timeouts hide slow endpoints and let stuck requests tie up workers.

Health checks and Always On

Enable Always On on production plans so the app is not unloaded after idle periods, which otherwise makes the first request after a quiet period slow. Configure Health check with a lightweight path such as /healthz/; App Service removes unhealthy instances from the load balancer rotation when you run more than one instance. Make sure that path returns 200 without authentication and passes ALLOWED_HOSTS validation for internal requests, or handle it in a middleware before host validation.

Cost and sizing notes

Basic tier plans are fine for small production apps but do not support deployment slots or autoscaling. Standard tier adds slots and scale rules. Gunicorn worker count should follow the plan's CPU count; the NUM_CORES environment variable exposed by App Service makes a formula such as --workers $((NUM_CORES*2+1)) possible in the startup command, but check memory first, because each worker loads its own copy of the application.

Next steps

FAQ

How does Azure App Service start a Django app?

It looks for a custom startup command first. If none is set, it searches for wsgi.py and runs Gunicorn bound to 0.0.0.0 with a 600-second timeout against that module. Setting your own startup command is recommended for production.

Does App Service run collectstatic automatically?

Yes, when build automation is enabled and Oryx finds manage.py in the project root. Set the DISABLE_COLLECTSTATIC app setting to true if you collect static files another way.

How should I run Django migrations on App Service?

Either run them manually through az webapp ssh after activating the Oryx virtual environment, or run them from your CI pipeline against the production database before switching traffic, for example before a slot swap.

Why does Django think requests are HTTP on App Service?

TLS terminates at the App Service front end, so your container receives plain HTTP with an X-Forwarded-Proto header. Set SECURE_PROXY_SSL_HEADER so Django treats those requests as secure.

Can I use SQLite on Azure App Service?

It is not a good fit. SQLite files on shared storage often produce "database is locked" errors, and each scaled-out instance would compete for the same file. Use Azure Database for PostgreSQL or another server database.

Keep going