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:
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.jsonexists:npm ciandnpm run build:js(if present) - HTML minify via
html-minifier-next(ENABLE_HTML_MINIFY,EXCLUDE_HTML_MINIFY) - SVG minify via
svgo(ENABLE_SVG_MINIFY; optionalcicd/svgo.config.mjs) - Optional Tailwind if
TAILWIND_INPUT_PATHis set (TAILWIND_OUTPUT_PATHoptional). Prefers./node_modules/.bin/tailwindcsswhen@tailwindcss/cliwas installed bynpm ci; otherwise falls back tonpx @tailwindcss/cli@latest. - Install Poetry, export
requirements.txtfor Oryx - Write
build-info.jsonfromSCM_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 formanage.py compilemessagesbuilt 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 installgettext(apt-getis refused for the build user). It scans everylocale/<lang>/LC_MESSAGES/*.pounder the project root (skippingantenv,node_modules,.git, …), writes the.monext to it, and likemsgfmtskips fuzzy entries and drops obsolete (#~) ones. A catalogue that does not parse fails the build. The.mofiles 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:
- Optional
EXTRA_APT_PACKAGES - 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. manage.py startup_tasks—migratefollowed bypurge_cachein one Django process. Eachmanage.pyinvocation 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.- Supervisord: RQ worker (
rqworkerwithdjango_tasks_rq.Job) started viasupervisorctlwhenDJANGO_TASKS=true; optionalrqcronstarted the same way whenDJANGO_RQ_CRON=true. Both programs are defined incicd/supervisord.confwithautostart=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. - Optional consumer hook
cicd/pre_startup.sh - Gunicorn via
cicd/gunicorn.conf.pyloadingcicd.wsgi(forwarded_allow_ips="*"; access logs includeX-Forwarded-ForandHostbecause the App Service front end connects from169.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_MODULEpointing at a module that imports this package’s Azure settings- Git repository wired via Pulumi
repository_url/repository_branch - Optional: committed overrides under
cicd/