How to deploy a Django app without Docker
Django's deployment checklist is long and mostly about settings. The container-shaped version of that checklist adds a Dockerfile, which for a standard Django app is ceremony rather than value — any platform with Python build detection can install your dependencies and run Gunicorn directly from the repo.
So here's the version without Docker: four decisions, four settings, and the symptoms when each is wrong. Commands are PandaStack's; the decisions are the same anywhere.
1. Pin the interpreter in the repo
Put the Python version in a file rather than a dashboard setting, so the same version resolves locally, in CI, and in production, and so it lands in code review when it changes.
echo "3.12" > .python-version
# And use a lockfile. Unpinned transitive dependencies mean the version
# resolved today is not the one resolved next month, and the difference
# shows up as a deploy that fails on a commit that changed nothing.2. Decide how static files are served
Gunicorn does not serve static files — that was the development server. Without a plan, your deploy succeeds and the site renders with no CSS, which is alarming and trivial.
WhiteNoise is the pragmatic answer: it serves the collected static files from inside the app process with correct cache headers, no extra infrastructure, and it scales further than people assume.
# settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# WhiteNoise goes immediately after SecurityMiddleware, before everything else
"whitenoise.middleware.WhiteNoiseMiddleware",
# ... the rest
]
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Hashed filenames + long-lived cache headers, compressed at collect time
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}3. Decide where migrations run
Not in the start command. If `migrate` runs when the app boots, every replica runs it on every restart, and a failing migration takes production down rather than blocking a deploy.
Run it once, after the build, before traffic shifts.
pandastack apps create --name web \
--git-url https://github.com/acme/shop \
--build-cmd 'pip install -r requirements.txt && python manage.py collectstatic --noinput' \
--start-cmd 'gunicorn shop.wsgi:application --workers 3 --timeout 60 --bind 0.0.0.0:$PORT' \
--env DJANGO_SETTINGS_MODULE=shop.settings.production
# Migrations as their own step, run once, before traffic moves
pandastack apps exec web -- python manage.py migrate --noinput4. Size the worker count from memory
Each Gunicorn worker is a full copy of your interpreter and imports — commonly 150–400 MB for a Django app with an ORM, DRF, and a few integrations. The popular 2×CPU+1 rule assumes memory is free. On a small instance it produces a configuration the OOM killer dismantles under load, and the log says only that a worker exited.
Measure one worker under real load, divide your instance memory by that, and keep a quarter as headroom. Three workers on a 2 GB instance is a reasonable starting point for a typical app. Set a request timeout too, so one slow view can't occupy a worker indefinitely.
The four settings that break on a new platform
- ALLOWED_HOSTS — Django returns 400 for a Host header it doesn't recognise. Add the platform-assigned hostname and every custom domain. The symptom looks like a routing failure and isn't.
- SECURE_PROXY_SSL_HEADER — if the platform terminates TLS at a proxy, Django thinks the request was HTTP and may redirect to HTTPS forever. An infinite redirect loop after adding SECURE_SSL_REDIRECT is always this.
- DEBUG — must be False. A Django error page in production leaks settings, including partial secrets, to anyone who triggers an exception.
- DATABASES — read the connection string from the environment, never from a committed settings file, and require TLS to a managed database.
import os
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
# Behind a TLS-terminating proxy — without this, SECURE_SSL_REDIRECT loops
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
DATABASES = {
"default": dj_database_url.parse(
os.environ["DATABASE_URL"],
conn_max_age=600, # reuse connections instead of reconnecting per request
ssl_require=True,
)
}If you use Celery
Deploy the worker as a second app from the same repository, with a different start command. If you use beat, it must run on exactly one instance — duplicated schedulers produce duplicate scheduled tasks, which is subtle, expensive, and usually discovered through a customer complaining about a duplicate email.
pandastack apps create --name worker \
--git-url https://github.com/acme/shop \
--start-cmd 'celery -A shop worker --loglevel=info --concurrency=2'
pandastack apps create --name beat \
--git-url https://github.com/acme/shop \
--start-cmd 'celery -A shop beat --loglevel=info'
# beat: exactly one instance, alwaysBefore you send real traffic
- Load a page and confirm CSS renders. No CSS means collectstatic or WhiteNoise.
- Upload a file, deploy, then look for the file. Gone means your media storage is ephemeral.
- Trigger a 500 and confirm you get Django's plain error page, not a debug traceback.
- Watch memory under load with your chosen worker count for at least ten minutes.
- Deploy a deliberately broken migration in staging and confirm it blocks the release rather than crash-looping the app.
The short version
Pin Python in the repo, WhiteNoise for static and object storage for media, migrations as a discrete pre-traffic step, worker count derived from measured memory, and the four settings above. That's a complete Django deployment with no Dockerfile and no container registry. Add a Dockerfile when you genuinely need system packages the build image lacks — not because a tutorial started with one.
Frequently asked questions
Can I deploy Django without a Dockerfile?
Yes. Any platform with Python build detection reads your requirements file or lockfile, installs dependencies, and runs the start command you give it — typically Gunicorn pointed at your WSGI application. Pin the interpreter version in the repo with a .python-version file so the build resolves the same wheels your lockfile assumed. A Dockerfile becomes worth adding when you need system packages the build image does not include, such as a specific libpq, image codecs, or a proprietary driver, or when you want the identical image running in CI and production. For a standard Django app with Postgres, it is ceremony.
Why does my Django site have no CSS after deploying?
Because Gunicorn does not serve static files — that was the development server's job, and it is disabled when DEBUG is False. You need collectstatic to run during the build, gathering every app's static files into STATIC_ROOT, and something to serve them. WhiteNoise is the simplest answer: add its middleware immediately after SecurityMiddleware, set a static files storage backend that adds content hashes, and the app serves them itself with correct long-lived cache headers. The alternative is pushing the collected files to object storage behind a CDN, which is better at high traffic and more moving parts to set up.
Why does Django return 400 Bad Request on a new host?
ALLOWED_HOSTS does not include the hostname in the request. Django compares the Host header against that list and rejects anything unrecognised with a 400, which reads like a proxy or DNS failure and is neither. Add the platform-assigned hostname and every custom domain you serve, reading them from an environment variable so they are not hard-coded. If you also see an infinite redirect loop, that is the companion problem: behind a TLS-terminating proxy Django believes the request arrived over HTTP, so SECURE_SSL_REDIRECT redirects it forever. Setting SECURE_PROXY_SSL_HEADER fixes that one.
Where should Django migrations run during a deploy?
In a discrete step that runs after the build and before traffic shifts, exactly once. Putting migrate in the start command means every replica runs it simultaneously on every restart, which races on long-running operations, and it converts a failed migration from a blocked deploy into a crash-looping production application. Run it as a one-off command from your deploy pipeline or the platform's release hook, and confirm that a failure there stops the release. Write migrations to be compatible with the currently running code as well, so a rollback does not leave the old version facing a schema it cannot use.
How many Gunicorn workers should I run for Django?
Derive it from measured memory rather than a formula. Each worker is a full copy of your interpreter and imports, commonly 150 to 400 MB for a Django app with an ORM and a few integrations, so divide your instance memory by that figure and keep at least a quarter as headroom. Three workers on a 2 GB instance is a sensible starting point. The frequently quoted 2×CPU+1 rule assumes memory is unlimited and produces a configuration the OOM killer takes apart under load, reported in the logs as nothing more informative than a worker exiting. Set a request timeout as well.
Keep reading
- App hosting on PandaStack — Python detected from your repo, no Dockerfile required
- The best Django hosting platforms in 2026
- The best Python hosting platforms in 2026
- How to deploy a Flask app without Docker
- Running background workers alongside a web app
49ms p50 cold start. Fork, snapshot, and scale to zero.