The best Django hosting platforms in 2026
Django hosting comparisons tend to be lists of platforms that can run Gunicorn, which is all of them. The interesting differences show up in the other three things every real Django deployment needs: somewhere to serve static and media files, a place to run migrations that isn't 'inside the app on startup', and a home for Celery workers and beat.
Judge platforms on those. I build PandaStack, one of the options below — flagged where it applies.
The four problems
1. The web process
Gunicorn with a worker count sized to memory, or an ASGI server if you use async views or channels. This is the easy part, and also where the most common resource mistake lives: each Gunicorn worker is a full copy of your interpreter and imports, commonly 150–400 MB for a Django app with the usual dependencies. Four workers on a 512 MB instance is an OOM kill under load, reported as an unexplained restart.
# Sync Django: worker count IS your concurrency, and it costs memory
gunicorn myproject.wsgi:application \
--workers 3 --timeout 60 --bind 0.0.0.0:$PORT
# Django with async views or Channels
gunicorn myproject.asgi:application \
-k uvicorn.workers.UvicornWorker --workers 2 --bind 0.0.0.0:$PORT2. Static and media files
Django's development server serves static files; Gunicorn does not. In production you either run `collectstatic` and serve the output with WhiteNoise inside the app process, or push it to object storage behind a CDN. WhiteNoise is the pragmatic default and is fine well past the point people assume it isn't.
Media files — user uploads — are a different problem, and the one that catches teams migrating from a single VPS. On any platform with an ephemeral filesystem, an uploaded file written to local disk disappears on the next deploy. It must go to object storage. This is not optional and it is not obvious, because it works perfectly in staging until the first redeploy.
3. Migrations
There is exactly one correct place for `migrate`: a release step that runs after the build, before traffic shifts, once. Not in the container entrypoint, where every replica runs it simultaneously and races. Not manually, where someone forgets.
This is the single biggest functional difference between hosting platforms for Django. Ask directly whether there's a pre-traffic release hook and whether a failing migration blocks the deploy. If migrations run at app startup instead, a bad migration converts 'deploy blocked' into 'production crash-looping', which is a much worse Tuesday.
4. Celery and beat
If you use Celery you need a broker (Redis or RabbitMQ), at least one worker process, and — if you use beat — exactly one scheduler. Platforms differ in whether a second process is a first-class concept or something you bolt on. Running beat on more than one instance produces duplicate scheduled tasks, which is subtle, expensive, and usually discovered via a customer email about a duplicate invoice.
Which platforms fit
- Container PaaS (Render, Railway, Fly.io, Heroku, Northflank): the default answer. Web plus worker plus beat as separate services, managed Postgres and Redis alongside, and most have a release-phase equivalent. Check the build memory ceiling if any dependency compiles from source.
- Serverless: a poor structural fit for Django. Cold starts scale with Django's import time, and Celery has no home. Viable for a small, read-heavy site with static-file offload; painful otherwise.
- A VPS with systemd and nginx: entirely legitimate, still the cheapest, and the reason a generation of Django apps ran for a decade. You own patching, TLS, and deploys.
- MicroVM platforms (Fly Machines, PandaStack — mine): PaaS-shaped deploys onto hardware-isolated VMs. Worth it when tenants must be separated at the kernel boundary, when the app executes code it didn't write, or when you provision an environment per customer. For a single Django app with a database, a container PaaS is simpler.
A no-Dockerfile Django deploy
# Pin the interpreter in the repo so the build resolves the same wheels
echo "3.12" > .python-version
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 --bind 0.0.0.0:$PORT'
# Migrations as a discrete step you run before shifting traffic —
# never inside the start command, where every replica races
pandastack apps exec web -- python manage.py migrate --noinputSix checks before you commit
- Confirm there's a release hook that runs migrations before traffic and blocks the deploy on failure.
- Upload a file, deploy, then look for the file. If it's gone, your media storage is ephemeral.
- Measure the memory of one Gunicorn worker under real load, then set the worker count from that, not from a formula.
- Start a Celery worker and beat as separate processes, and confirm beat runs on exactly one instance.
- Restore a database backup on the new platform, timed, before you migrate anything real.
- Check the build container's memory if anything in your requirements compiles from source — psycopg2, Pillow with codecs, or a Rust extension.
The short version
For most Django apps, a container PaaS with managed Postgres, a release hook for migrations, WhiteNoise for static files, and object storage for media is the whole answer, and the choice between the big three matters less than getting those four things right. Serverless is a bad structural fit. A VPS is still fine for one app. MicroVMs are for when isolation is a requirement you have to defend, not a nice-to-have.
Frequently asked questions
Where should Django migrations run during a deploy?
In a release step that runs after the build and before traffic shifts, exactly once. Running migrate in the container entrypoint means every replica runs it simultaneously on every restart, which races — Django takes a lock for a single migration but concurrent processes can still collide on long-running operations — and, worse, turns a failed migration into a crash-looping production app instead of a blocked deploy. If your platform has no pre-traffic release hook, run migrations as a one-off command from CI after the build succeeds and before you promote the new version.
Why do my Django user uploads disappear after a deploy?
Because MEDIA_ROOT points at local disk and the platform's filesystem is ephemeral. Every deploy creates a fresh filesystem, and anything written to the old one is gone. Nothing errors, which is why this is usually discovered weeks later as broken images. The fix is to configure a storage backend that writes to object storage — S3, GCS, or an S3-compatible service — via Django's storages framework, and to migrate the files you still have before the next deploy. Static files are a separate concern: those are regenerated by collectstatic at build time and are safe to keep on local disk with WhiteNoise.
How many Gunicorn workers should a Django app run?
Derive it from memory, not from a formula. Measure the resident memory of a single worker under real load — a typical Django app with an ORM, DRF, and a few integrations lands between 150 and 400 MB — then divide the instance memory by that and leave at least a quarter as headroom. The popular 2×CPU+1 rule assumes memory is unlimited, and on a small instance it produces a configuration that the OOM killer dismantles under load, reported in the logs as nothing more than a worker exiting. Also set a request timeout, so one slow view can't tie up a worker indefinitely.
Can I host Django on serverless functions?
It can be made to work and it usually shouldn't be. Django's import time makes cold starts noticeable, there is no long-lived process for Celery workers or beat, and connection pooling against Postgres becomes a problem because each concurrent invocation opens its own connection and a modest traffic spike exhausts the database's connection limit. If you go this route you need a connection pooler in front of Postgres, static and media entirely offloaded, and a separate home for background work — at which point you have assembled most of a PaaS. A long-lived process is the shape Django was designed for.
Why does Django return 400 Bad Request on a new hosting platform?
ALLOWED_HOSTS doesn't include the hostname the request arrived on. Django checks the Host header against that list and rejects anything unrecognised with a 400, which looks like a proxy or routing failure and isn't. Add the platform-assigned hostname and every custom domain you serve. If your platform terminates TLS at a proxy, you may also need SECURE_PROXY_SSL_HEADER set so Django knows the original request was HTTPS — without it, redirects can loop between HTTP and HTTPS forever. Both are configuration, not platform faults, and both are ten-second fixes once you know.
Keep reading
- App hosting on PandaStack — Python detected from your repo, no Dockerfile required
- How to deploy a Django app without Docker
- The best Python hosting platforms in 2026
- Zero-downtime schema migrations on deploy
- Running background workers alongside a web app
49ms p50 cold start. Fork, snapshot, and scale to zero.