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
collectstaticruns 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.
Quick answer
- Put
requirements.txt(withgunicorn,whitenoise, andpsycopg[binary]) in the repository root, next tomanage.py. - Read
SECRET_KEY,DEBUG, database settings, andALLOWED_HOSTSfrom environment variables; useWEBSITE_HOSTNAMEfor the default host. - Add WhiteNoise and set
STATIC_ROOT; Oryx runscollectstaticduring the build when it findsmanage.py. - Create a resource group, an App Service plan, a Linux web app, and a PostgreSQL Flexible Server.
- Set app settings, including
SCM_DO_BUILD_DURING_DEPLOYMENT=1. - Set an explicit Gunicorn startup command with
az webapp config set --startup-file. - 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.
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"
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
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 tohttps://python manage.py check --deployinside 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
stagingslot, verify, thenaz 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:
| Symptom | Where to look | Usual cause |
|---|---|---|
| Deployment fails | Deployment Center logs (Oryx build) | dependency file not in root, package build failure, collectstatic error |
| Default welcome page | SSH into the container, check $APP_PATH | build automation disabled, app files not deployed, wsgi.py not found |
"Application Error" or 503 | Log stream | exception at import time, wrong module in startup command, missing app setting |
| Unstyled admin | browser network tab | WhiteNoise middleware missing or collectstatic skipped |
400 Bad Request | Log stream with DisallowedHost | custom domain missing from allowed hosts |
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
.venvbuilt on macOS or Windows contains incompatible binaries. Exclude it from the package and let Oryx build. - Setting
DEBUGto the string"False"and reading it withbool(). 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
- Harden database connectivity in Connect Django to Managed PostgreSQL Securely.
- Move secrets out of plain settings with Manage Django Secrets in Production.
- If Gunicorn cannot import your project, work through Fix ModuleNotFoundError When Starting Django with Gunicorn.
- Review WhiteNoise behavior in How to Serve Django Static Files with WhiteNoise.
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.