The Django application runs flawlessly on your laptop, but once you deploy it to a server, you get “Bad Request (400)”, an unstyled admin panel, or a blank page with a 500 error. This isn't bad luck, just a few recurring pitfalls that almost everyone falls into on their first deployment. Django's development environment is deliberately forgiving: it displays errors, serves static files automatically, and doesn't ask about your domain. Production is not so forgiving.
In this guide, we've collected the 10 most common mistakes made when deploying Django to production. For each one, we explain how it manifests, what causes it, and how to fix it. At the end, you'll find a quick checklist to review before every deployment, along with answers to the most common questions.
Table of contents
- Why does Django work locally but not on a server?
- Is DEBUG = True in production a big problem?
- Where should you keep SECRET_KEY and passwords?
- What causes “Bad Request (400)”?
- Why does “CSRF verification failed” appear behind a proxy?
- Why does the admin panel have no styles?
- Can you leave runserver running?
- What happens when you forget about migrations?
- Is SQLite enough for production?
- Why does pip install break a deployment?
- How do you know the application has crashed?
- Pre-deployment checklist
- What does it look like on Saplo?
- FAQ
Why does Django work locally but not on a server?
The python manage.py runserver command starts a development server that quietly does a lot of the programmer's work for them. It serves CSS and JavaScript files, displays a readable page with a stack trace when there is an error, accepts requests under localhost, and does not concern itself with HTTPS.
In production, this convenience disappears. The application sits behind a web server or reverse proxy, runs under a real domain, traffic comes in encrypted, and every element that "worked on its own" locally has to be configured explicitly. Most of the errors described below are simply the difference between these two worlds.
Before you go any further, remember one command. Django has a built-in production settings audit:
python manage.py check --deploy
It lists warnings about DEBUG, the secret key, cookies, HTTPS, and security headers. Running it before your first deployment catches half of the problems on this list.
Mistake 1: Is DEBUG = True in production a big problem?
A big one. With DEBUG enabled, every 500 error shows visitors the full diagnostic page: code snippets, local variables, server paths, the settings list, and SQL queries. Django masks some sensitive names, but not all, and the application's structure itself is a valuable map for an attacker. On top of that, DEBUG mode stores every database query, so the process's memory usage grows over time.
How to fix: set DEBUG using an environment variable, with a safe default value.
import os
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
You set DJANGO_DEBUG=1 locally and don't set anything on the server. Just remember that once you turn DEBUG off, ALLOWED_HOSTS starts to apply, and Django stops serving static files. That's why errors 3 and 5 usually appear right after this is fixed.
Error 2: Where should you keep SECRET_KEY and passwords?
Not in the repository. SECRET_KEY signs sessions, password reset tokens, and CSRF cookies. Anyone who knows it can forge a logged-in user's session. A key generated by startproject often ends up in the first commit and stays in the git history forever, even after it is removed from the file.
The same applies to database passwords and API keys for payments or email.
How to fix: read all secrets from environment variables and leave only their names in the repository.
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
A missing variable will cause an error at startup, which is a good thing: it is better for the application not to start than to start with a default key. If the key has already ended up in a public repository, generate a new one. All users will be logged out, but the old key will no longer be a threat.
Error 3: Where Does “Bad Request (400)” Come From?
It almost always comes from an empty or incomplete ALLOWED_HOSTS. With DEBUG disabled, Django rejects every request whose Host header is not on this list. Typical omissions include not specifying a version with www, not including the temporary subdomain under which the app runs before a domain is connected, or a typo.
How to fix: enter all domains under which the app should respond.
ALLOWED_HOSTS = ["yourcompany.com", "www.yourcompany.com"]
Do not try to fix it by adding ["*"]. This disables protection against Host header attacks, such as inserting a fake domain into password reset links.
Error 4: Why does “CSRF verification failed” appear behind the proxy?
In production, Django rarely receives traffic directly. A reverse proxy sits in front, accepting HTTPS and forwarding plain HTTP to the application. Django therefore sees an unencrypted request, while the browser submits the form from the address https://. Since version 4.0, Django compares the Origin header with a list of trusted origins and rejects forms without it, including login to the admin panel.
A second variation of the same problem occurs when you enable SECURE_SSL_REDIRECT = True, and the page enters an endless redirect loop because Django keeps thinking the request came over HTTP.
How to fix: tell Django which proxy header carries the HTTPS information and add the trusted origins.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = ["https://yourcompany.com", "https://www.yourcompany.com"]
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
The SECURE_PROXY_SSL_HEADER setting is only secure if the proxy always sets this header itself and does not pass through a value from the client.
Mistake 5: Why is the admin panel unstyled?
Because once you turn off DEBUG, Django no longer serves static files. The admin panel /admin/ works, but looks like a page from the nineties, and custom CSS and images return 404 errors. Then there is the forgotten collectstatic: the files sit in application directories, but nobody has collected them in one place.
How to fix it: set STATIC_ROOT, collect the files on every deployment, and serve them through a web server or the WhiteNoise library.
STATIC_ROOT = BASE_DIR / "staticfiles"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# reszta middleware
]
python manage.py collectstatic --noinput
WhiteNoise only supports static files from your code. User-uploaded files (MEDIA_ROOT, such as product photos) have to be served separately through a web server or an external file storage. This is often a second job right after the first.
Error 6: Can you leave runserver running?
No. The Django documentation states plainly that the development server has not passed security audits or performance tests. It handles requests in a way that is unsuitable for traffic, offers no sensible process management, and nobody will restart it if it crashes.
How to fix: run the application through a WSGI server, most commonly Gunicorn, under the control of a process manager that will restart it after a crash or machine reboot.
gunicorn yourproject.wsgi:application --bind 127.0.0.1:8000 --workers 2
The number of worker processes depends on available memory. Each Gunicorn process is a separate copy of the application in RAM, so on a small server with 1 GB, it is better to start with two and monitor usage than set it to eight and have the system kill the processes.
Error 7: What happens when you forget about migrations?
The new version of the code references a column that is not yet in the database. Any page using this model returns a 500 error, and no such column or relation does not exist appears in the logs. Another variation of this error involves a migration file that did not make it into the repository because it was in .gitignore or no one added it.
How to fix: treat migrations as a consistent step in every deployment, not a manual task.
python manage.py makemigrations --check --dry-run
python manage.py migrate --noinput
The first command, run before the commit, ends with an error if the models contain changes without a migration file. The second is run on the server before starting the new version. Before a migration that removes columns or tables, make a database backup.
Mistake 8: Is SQLite enough for production?
For a small application with a few users and few writes, SQLite can run for years. Problems begin with many simultaneous writes (database is locked) and backups: a database file copied while a write is in progress can become corrupted.
The real mistake is not choosing SQLite itself, but storing the database somewhere it disappears during deployment. A db.sqlite3 file in a directory that is overwritten with every deployment, or in a container without a persistent volume, means that the next deployment starts with an empty database.
How to fix it: if the application is going to grow, switch to PostgreSQL and provide the database address in the DATABASE_URL environment variable. Regardless of the database engine, make sure the data is stored in a persistent location and covered by backups.
Error 9: Why does pip install break deployment?
Because the local environment differs from the one on the server. Typical scenarios: a requirements.txt file without a version, so the server downloads a newer, incompatible library; a package installed manually locally that is not listed in the file (No module named ...); or a different version of Python. A real-world example: Django 6.0 requires Python 3.12 or newer, so on a server running Python 3.11, the pip install django command without a version constraint will fail or install a version that will not run.
How to fix: pin versions and check which Python version is running on the server.
pip freeze > requirements.txt
python --version
If the server is running Python 3.11, enter Django>=5.2,<6 in the requirements file. The 5.2 branch has long-term support, so it is a sensible choice for production.
Error 10: How do you know the application has crashed?
The worst scenario is when a customer reports the outage. With DEBUG disabled, errors are not visible on the site, so if they do not make it to the logs or email, they simply disappear.
How to fix:
- configure
LOGGINGso that errors go to standard output or to a file that you can inspect after an outage, - set
ADMINSand the correct email configuration so that Django sends an email for every 500 error, - add a simple health check endpoint, for example
/health/, which returns{"status": "ok"}, and hook it up to availability monitoring.
A health check endpoint is also useful during deployments: the deployment system can check whether the new version is up before switching traffic to it.
Pre-deployment checklist
Before you release a new version, go through these items:
| Item | How to check |
|---|---|
| DEBUG disabled | DJANGO_DEBUG is not set on the server |
| Secrets outside the repository | git grep SECRET_KEY does not expose values |
| Domains in ALLOWED_HOSTS | version with and without www, plus a temporary subdomain |
| HTTPS behind a proxy | SECURE_PROXY_SSL_HEADER and CSRF_TRUSTED_ORIGINS configured |
| Static files | collectstatic during the deployment process, admin has styles |
| Application server | Gunicorn instead of runserver |
| Migrations | makemigrations --check passes, migrate during the deployment process |
| Database and backups | data in persistent storage, backup works |
| Dependencies | pinned versions, compatible with Python on the server |
| Logs and monitoring | errors are logged, /health/ responds |
| Django audit | python manage.py check --deploy with no warnings |
What does it look like on Saplo?
On Saplo, most of this list is already set up before you write the first line of code. When you create an app using Django from the catalog, the recipe installs Gunicorn and WhiteNoise, configures STATIC_ROOT, adds the domain to ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS, adds SECURE_PROXY_SSL_HEADER, runs migrations and collectstatic, and the application process runs under a process manager that restarts it after a crash. An SSL certificate for the domain is included in the plan.
When you deploy your own repository, the saplo.yaml file controls the steps. Migrations are specified as a pre-build hook, and static file collection as a build command:
build:
install: "pip install -r requirements.txt"
command: "python manage.py collectstatic --noinput"
hooks:
pre_build: "python manage.py migrate --noinput"
A few things worth knowing:
- Python 3.11. Builder runs the system Python 3.11, so keep Django on the 5.x branch in
requirements.txt(issue 9). - Secrets. You can set
SECRET_KEYor the database password with thesaplo env set --secretcommand, or in the dashboard under Variables and secrets. The dashboard, API, and AI assistant will then show only asterisks. The change takes effect from the next deployment or restart. See the variables and secrets documentation. - A failed deployment doesn't take the site down. If either the build or the health check fails, the new version gets an error status, while the previous version keeps running. The health check waits up to 60 seconds for a response.
- PostgreSQL is set up as a container in the application's
docker-compose.ymlfile, with the port exposed only on127.0.0.1and the address in theDATABASE_URLvariable. - Backups. Every Box has a daily encrypted backup replicated to a different data center (7 days of retention on Box S, Box M, and Box L). Starting with Box L, you also get automatic snapshots for one-click restore, and you can create a manual snapshot on any plan before a risky migration.
- Logs can be viewed with the
saplo logs --followcommand or in the dashboard under deployment details.
The Git deploy guide explains how to deploy from GitHub after every git push, and we've collected common error messages in troubleshooting.
Which plan? Box S is enough for a small Django app (1 GB RAM, $29 per year). If you need SSH access to the server or a PostgreSQL database in a separate container, Box M is a sensible starting point (2 GB RAM, SSH, $69 per year). Box L (6 GB RAM, $149 per year) adds automatic snapshots with 7-day retention. The first-year price is the same as the renewal price, and we migrate an existing app from another hosting provider for free. See the full plan comparison in the pricing page.
If you're just getting started with Django, read what Django is and what it's best suited for first. You can find the setup instructions in the guide how to set up Django on Saplo.
FAQ
How to check whether my Django app is ready for production?
Run python manage.py check --deploy with production settings. The command will print warnings about DEBUG, the secret key, HTTPS, cookies and security headers. Then go through the checklist in this article, because the audit does not check migrations, backups or static files.
Why does the site show Bad Request (400) after disabling DEBUG?
Because from that point on, Django checks ALLOWED_HOSTS, and the domain you use to access the site is not on the list. Add all URL variants, including the one with www and the temporary subdomain, and restart the application.
Is WhiteNoise enough for static files in production?
For most small and medium-sized applications, yes. WhiteNoise serves compressed files with a long browser cache lifetime. However, it does not support files uploaded by users, which need to be served separately.
How much RAM does Django need in production?
A simple application with two Gunicorn processes fits in a few hundred megabytes, so a server with 1 GB of RAM is enough to get started. More memory is needed when using a PostgreSQL database on the same server, a background task queue, or a larger number of worker processes.
Can I move a working Django application to Saplo?
Yes. The application is deployed from a Git repository or via the CLI, and we move it from another hosting provider free of charge on every plan. Before the migration, it is worth going through the checklist from this article, because settings dependent on the old server, such as domains or file paths, need to be adjusted to the new one.
- #Security
- #Django
- #Guide
- #Python
- #Deployment
