Skip to content

Deploy pipeline

App Service is configured with Oryx builds (SCM_DO_BUILD_DURING_DEPLOYMENT=true). Collectstatic is disabled during Oryx (DISABLE_COLLECTSTATIC=true) and runs at startup instead.

Bootstrap

PRE_BUILD_COMMAND is:

curl -sSL https://bootstrap.django-azu.re | bash

That downloads scripts from this repository’s deploy_scripts/ into the app checkout:

Destination Files
cicd/ pre_build.sh, post_build.sh, startup.sh, collectstatic.sh, gunicorn.conf.py, supervisord.conf, wsgi.py, html-minifier.json
utility/ pgdump.sh

Existing files are skipped, so you can vendor/customize by committing your own copies under cicd/.

Branch override: set App Setting CICD_SCRIPTS_BRANCH (default main).

After download, bootstrap runs cicd/pre_build.sh.

Pre-build (cicd/pre_build.sh)

  • If package.json exists: npm ci and npm run build:js (if present)
  • HTML minify via html-minifier-next (ENABLE_HTML_MINIFY, EXCLUDE_HTML_MINIFY)
  • SVG minify via svgo (ENABLE_SVG_MINIFY; optional cicd/svgo.config.mjs)
  • Optional Tailwind if TAILWIND_INPUT_PATH is set (TAILWIND_OUTPUT_PATH optional). Prefers ./node_modules/.bin/tailwindcss when @tailwindcss/cli was installed by npm ci; otherwise falls back to npx @tailwindcss/cli@latest.
  • Install Poetry, export requirements.txt for Oryx
  • Write build-info.json from SCM_COMMIT_ID

Post-build (cicd/post_build.sh)

Runs as POST_BUILD_COMMAND, inside the Oryx build container with the antenv virtualenv active.

  • Prunes npm production deps when a lockfile is present.
  • Compiles translations: python -m pulumi_django_azure.compilemessages. This is a settings-free, pure-Python replacement for manage.py compilemessages built on Babel, because the build container (Kudu, Ubuntu, non-root) can import neither Django settings (they reach Key Vault and the database at import time; the build has no VNet access) nor install gettext (apt-get is refused for the build user). It scans every locale/<lang>/LC_MESSAGES/*.po under the project root (skipping antenv, node_modules, .git, …), writes the .mo next to it, and like msgfmt skips fuzzy entries and drops obsolete (#~) ones. A catalogue that does not parse fails the build. The .mo files end up in the compressed build output, so nothing is compiled at container start.

Consumer apps can commit their own cicd/post_build.sh; _bootstrap.sh never overwrites an existing file.

Startup (cicd/startup.sh)

Configured as the site startup command by the package’s deploy layout:

  1. Optional EXTRA_APT_PACKAGES
  2. Background nice -n 19 collectstatic.sh (collectstatic + purge_cdn). Lowest CPU priority on purpose: on a single-core plan it would otherwise slow down the steps the platform's warm-up probe is waiting for.
  3. manage.py startup_tasks — migrate followed by purge_cache in one Django process. Each manage.py invocation pays a full Django boot (settings incl. Key Vault, app registry, Wagtail); on a B1 under load that is 20-50 s per invocation, so steps that must run inside Django are batched.
  4. Supervisord: RQ worker (rqworker with django_tasks_rq.Job) started via supervisorctl when DJANGO_TASKS=true; optional rqcron started the same way when DJANGO_RQ_CRON=true. Both programs are defined in cicd/supervisord.conf with autostart=false, so an app that does not enable them pays nothing for them, and both log to the container's stdout/stderr so their output appears in the App Service log stream.
  5. Optional consumer hook cicd/pre_startup.sh
  6. Gunicorn via cicd/gunicorn.conf.py loading cicd.wsgi (forwarded_allow_ips="*"; access logs include X-Forwarded-For and Host because the App Service front end connects from 169.254.x.x)

Gunicorn does not size its worker pool from the plan's CPU count. multiprocessing.cpu_count() reports the plan's cores to every app on the plan, so a cores-based formula multiplies processes - and therefore memory and blast radius - on a shared plan, where swap exhaustion recycles the site and loses in-flight work.

Workers are therefore a fixed, explicit number (default 2), and concurrency is raised with threads (default min(8, cores * 2)): a thread costs ~1-2 MB of stack against ~60-100 MB per worker process. Effective concurrency is workers x threads.

App Setting Default Purpose
GUNICORN_WORKERS 2 Worker processes per app. Raise to 3-4 only when the app is alone on its plan and CPU-bound.
GUNICORN_THREADS min(8, cores * 2) Concurrency per worker (gthread).
GUNICORN_WORKER_CLASS gthread Set to sync to opt out of threaded workers; GUNICORN_THREADS is then ignored.
GUNICORN_MAX_REQUESTS 500 Requests per worker before it is recycled, bounding slow memory growth.
GUNICORN_MAX_REQUESTS_JITTER 50 Staggers worker recycling.

If a plan still swaps with 2 workers, the footprint is per process (the always-on rqworker, auto-instrumentation, or DEBUG=True) rather than the worker count.

Example access log line:

203.0.113.4 [28/Aug/2026:09:05:23 +0200] "GET /path HTTP/1.1" 404 9 "Mozilla/5.0" host=www.example.com

The first field is the client IP from X-Forwarded-For. When it is empty or -, the request likely came from an internal Azure probe with no forwarded headers.

What the consumer app must provide

  • A Poetry project that exports cleanly to requirements.txt
  • DJANGO_SETTINGS_MODULE pointing at a module that imports this package’s Azure settings
  • Git repository wired via Pulumi repository_url / repository_branch
  • Optional: committed overrides under cicd/