How to host a Django app on your own domain

runserver is for your laptop. On a server, Django needs a real WSGI server, settings that hold up with DEBUG off, its static files served, and HTTPS in front. Here is each part, with the code.

8 min readIntermediateUpdated

1. Run it under gunicorn, not runserver

manage.py runserver is Django's development server, and Django's own documentation says not to use it in production. It is slow under load, and with DEBUG = True every error page shows your settings, your code and the request to whoever caused it.

On a server, run Django under gunicorn, which keeps a few worker processes and replaces any that die. Add it to requirements.txt with the two other packages this guide uses:

Text
django
gunicorn
whitenoise
python-dotenv

2. Settings that are safe with DEBUG off

Keep the secret key and anything that differs between your laptop and the server in a .env file next to manage.py, and read it at the top of settings.py:

Python
# settings.py
import os
from pathlib import Path

from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG") == "1"
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
.env
# .env (never in Git)
DJANGO_SECRET_KEY=paste-the-generated-key-here
DJANGO_ALLOWED_HOSTS=example.com,www.example.com

Generate the key with python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" and add .env to .gitignore. On your laptop, put DJANGO_DEBUG=1 and DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 in its .env.

ALLOWED_HOSTS is the usual reason a site that works locally answers 400 Bad Request on the server: with DEBUG off, Django refuses any request for a host name not in the list. CSRF_TRUSTED_ORIGINS lists the addresses, with https://, that forms may be posted from. Behind a proxy that handles HTTPS, Django sees http:// while the browser says https://, and without this (or the next section) every login and form fails with "CSRF verification failed. Request aborted."

3. Tell Django it is behind HTTPS

When HTTPS is handled in front of your app, Django receives plain HTTP and treats every request as insecure: request.is_secure() is false and the full links it builds, in emails and redirects, start with http://. The proxy says what really happened in a header, and one setting makes Django read it:

Python
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Set SECURE_PROXY_SSL_HEADER only when a proxy in front sets X-Forwarded-Proto itself, overwriting whatever the visitor sent. On SnowServers it does. Leave SECURE_SSL_REDIRECT off there: the proxy already sends http:// visitors to https://, so Django does not need to.

4. Static files with WhiteNoise

With DEBUG off, Django stops serving /static/, and the site loses its CSS. WhiteNoise serves those files from your app, compressed and with long cache headers, with no separate web server:

Text
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...the rest of your middleware, as it was
]

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

WhiteNoise goes straight after SecurityMiddleware. collectstatic (next step) copies every app's static files into staticfiles, and that folder is what WhiteNoise serves. It covers your own CSS, JavaScript and images, not files your users upload; those need a view of their own or object storage.

5. One start file that migrates, collects and starts

A small main.py next to manage.py brings the database up to date, collects the static files and then starts gunicorn, so every restart, and every deploy, does all three:

Python
# main.py: migrate, collect static files, then start gunicorn
import os
import subprocess
import sys

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
manage = [sys.executable, "manage.py"]
subprocess.run(manage + ["migrate", "--noinput"], check=True)
subprocess.run(manage + ["collectstatic", "--noinput"], check=True)

port = os.environ.get("SERVER_PORT") or os.environ.get("PORT") or "8000"
os.execv(sys.executable, [
    sys.executable, "-m", "gunicorn",
    "--workers", "2",
    "--bind", f"0.0.0.0:{port}",
    "--access-logfile", "-",
    "mysite.wsgi:application",
])

Change mysite to the folder that holds your settings.py and wsgi.py. The app listens on 0.0.0.0, every interface, and on the port the host gives it: SERVER_PORT on SnowServers, often PORT elsewhere. If a migration fails, check=True stops the start and the error is in the console, so the site never runs against a half-migrated database. Two workers suit a 1 GB plan; each one is a full copy of your app in memory.

6. The database

Django's default SQLite database is a single file, db.sqlite3, and for a small site on one server it is fine: it lives with your files, so the server's backups include it. Move to MySQL or PostgreSQL when several processes write a lot at once.

Every SnowServers plan includes a MySQL database, with its details in the panel (databases). Put them in .env as DB_HOST, DB_NAME, DB_USER and DB_PASSWORD, and point Django at it:

Python
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "HOST": os.environ["DB_HOST"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "OPTIONS": {"charset": "utf8mb4"},
    }
}

Django's MySQL backend uses the mysqlclient package, which compiles against MySQL's C library. If it will not install, add pymysql to requirements.txt instead and put import pymysql; pymysql.install_as_MySQLdb() at the top of settings.py.

7. Check it before it goes up

Terminal
python manage.py check --deploy

Run it with your production .env. It lists the settings Django considers unsafe for a live site; read each one rather than silencing them all. Behind a proxy, two warnings are expected: security.W008 about SECURE_SSL_REDIRECT, which the proxy does, and security.W004 about HSTS, which you can add once HTTPS works. A project started with Django 6.1 or later also reports mail.E001: its MAILERS setting prints email to the console. That is right until the site sends email; when it does, set a real backend there, such as SMTP.

Hosting it on SnowServers

  1. Choose a plan on the website plans page and pick A website and Python at checkout. Flurry, at 1 GB, runs a typical Django site.
  2. Upload the project in Files with manage.py, main.py and requirements.txt at the top, or paste your GitHub repository into Git repository on the Startup tab. Put .env in by hand.
  3. main.py is picked as the start file on its own, ahead of the starter app.py; you can delete the starter. To be explicit, set Start file on the Startup tab.
  4. Restart from the console. It shows the packages installing, the migrations, collectstatic and gunicorn's workers booting.
  5. On the Website tab, add your domain and the DNS records it shows. HTTPS follows on its own, usually within a few minutes. Put the same domain in DJANGO_ALLOWED_HOSTS.

Uploads through your domain can be up to 25 MB each, and a request that sends nothing back for two minutes is cut off by the proxy. The website docs cover the rest, including deploying on every push.

Common problems

  • 400 Bad Request on every page: the domain is missing from ALLOWED_HOSTS.
  • "CSRF verification failed. Origin checking failed": SECURE_PROXY_SSL_HEADER is missing, or the address is not in CSRF_TRUSTED_ORIGINS.
  • The site has no CSS: WhiteNoise is not in MIDDLEWARE, or collectstatic did not run.
  • collectstatic fails with "could not be found": a CSS file refers to an image or font that is not there. The manifest storage checks every reference; fix the path, or remove the reference.
  • "This site is not answering": gunicorn is listening on 127.0.0.1 or the wrong port. Bind to 0.0.0.0 and SERVER_PORT.
  • WORKER TIMEOUT in the log: a request took longer than gunicorn's 30 seconds. Make the slow work faster or move it to a background job.

Press / or Ctrl K on any page to search.