How to deploy Django to a VPS with Gunicorn
Serve a Django 6 project with Gunicorn and WhiteNoise in Docker, run collectstatic in the image and migrations before traffic, and connect it to PostgreSQL.
Updated
This guide puts a Django 6 project in production on your own server, served by Gunicorn, with its static files collected into the image and its migrations run before any traffic. The settings and the Dockerfile work on any VPS that runs containers, and the last sections deploy it with Tenkai and a PostgreSQL database.
We tested it on 2 November 2026 with a new project from django-admin startproject, on Django 6.1.2, Gunicorn 26.2 and python:3.14-slim, so adapt the project name and the packages to your own.
Prerequisites
You need a Django 6 project in a Git repository with a CI such as GitHub Actions, named mysite in the examples. Docker is useful to test the image on your machine, and the Tenkai part needs a project on a ready cluster.
Add the production packages
Add these packages to requirements.txt, which the image installs.
Django==6.1.2
dj-database-url==3.1.2
gunicorn==26.2.0
psycopg[binary]==3.3.6
whitenoise==6.12.0
Gunicorn serves the project, psycopg[binary] connects to PostgreSQL without compiling anything, dj-database-url reads the database from one DATABASE_URL, and WhiteNoise serves the static files from Gunicorn itself.
Read the settings from the environment
Open mysite/settings.py. Import os and dj_database_url at the top, then replace the hard-coded secret key, debug flag and hosts.
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
DEBUG = os.environ.get('DJANGO_DEBUG') == '1'
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost').split(',')
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
DEBUG stays off unless you turn it on, because a debug page shows your settings to anyone who reaches an error. SECURE_PROXY_SSL_HEADER tells Django that the proxy in front of it received the request over HTTPS. Without it, Django believes each request is plain HTTP, and every form posted from your HTTPS page fails its CSRF check with 403.
With DEBUG off, Django writes no traceback for a failing request anywhere you can read it. Send its logs to the console, which the container's logs collect.
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {'console': {'class': 'logging.StreamHandler'}},
'root': {'handlers': ['console'], 'level': 'INFO'},
}
Replace DATABASES, so the project reads DATABASE_URL and keeps SQLite on your machine.
DATABASES = {
'default': dj_database_url.config(
default=f'sqlite:///{BASE_DIR / "db.sqlite3"}',
conn_max_age=600,
conn_health_checks=True,
),
}
Serve the static files with WhiteNoise
With DEBUG off, Django serves no static file, so the admin and your pages load without their styles. Add WhiteNoise right after SecurityMiddleware in MIDDLEWARE.
'whitenoise.middleware.WhiteNoiseMiddleware',
Then tell collectstatic where to write the files and how to store them.
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STORAGES = {
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
'staticfiles': {'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage'},
}
The manifest storage gives each file a name with its hash, so browsers cache it for good and fetch the new one after each deployment. A template that refers to a file collectstatic never found raises an error on that page, which you catch by testing the image before you deploy it.
Add a health check
Django refuses a request whose Host header is not in ALLOWED_HOSTS with 400, and a health check that calls the container directly uses the container's own name. Answer it in a middleware that runs before that check, in mysite/health.py.
from django.db import connection
from django.http import HttpResponse
def health_check(get_response):
# Answers before Django checks the Host header, which a health check
# that calls the container directly does not match.
def middleware(request):
if request.path == '/health':
with connection.cursor() as cursor:
cursor.execute('SELECT 1')
return HttpResponse('ok')
return get_response(request)
return middleware
Put 'mysite.health.health_check' first in MIDDLEWARE. /health answers ok once the database answers a query and 500 while it does not. A query matters here, because Django keeps its connection open between requests and would not notice a database that stopped, and every other path still checks ALLOWED_HOSTS.
Add a Dockerfile
Create Dockerfile at the repository root.
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8080 \
WEB_CONCURRENCY=3
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN DJANGO_SECRET_KEY=collectstatic python manage.py collectstatic --noinput
RUN useradd --create-home app
USER app
EXPOSE 8080
CMD ["gunicorn", "mysite.wsgi", "--access-logfile", "-"]
collectstatic runs in the build, so each image carries its own static files. It needs a secret key only to load the settings, which is why the build gives it a placeholder that never reaches the running container.
Gunicorn listens on 0.0.0.0 at PORT when you give it no --bind, and starts WEB_CONCURRENCY worker processes. Each worker holds a copy of your project in memory, about 60 MB each for a new project and more as yours grows, so lower the number on a small server. PYTHONUNBUFFERED=1 sends each log line to the logs as it is written, and Gunicorn finishes the requests in progress when it receives SIGTERM.
Exclude local files from the image
Create .dockerignore beside the Dockerfile.
.git
.venv
__pycache__
*.pyc
db.sqlite3
staticfiles
.env
.env.*
Run the migrations before traffic
Run the migrations once for each deployment, before the new version serves anyone.
python manage.py migrate --noinput
Keep them out of the CMD, where each replica would run them as it starts. The previous version still serves while they run, so add fields in one deployment and remove the old ones in a later one.
Test the image locally
Try the image with a PostgreSQL container.
docker build -t hello-django .
docker network create hello-django
docker run -d --name hello-django-db --network hello-django \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app postgres:18
cat > .env.docker <<'EOF'
DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost
DATABASE_URL=postgresql://postgres:secret@hello-django-db:5432/app
EOF
docker run --rm --network hello-django --env-file .env.docker hello-django python manage.py migrate --noinput
docker run --rm --name hello-django --network hello-django -p 8080:8080 --env-file .env.docker hello-django
Give PostgreSQL a few seconds to start before the migrations. Visit http://localhost:8080/admin/ and expect the styled login page, then remove the containers and the network. Keep .env.docker out of Git, since it holds your test secrets.
Publish the image from your CI
Build the image in your CI on each push and publish it to a container registry, so your servers spend nothing on builds and your code can stay private. Build an image in GitHub Actions and publish it to GHCR gives a complete workflow for this Dockerfile, built for your cluster's CPU architecture, which publishes an image such as ghcr.io/acme/hello-django:main on each push to main.
Deploy it with Tenkai
- Create the database. Create a PostgreSQL managed service and wait until it is running.
- Create the application. Choose Add resource, then Application, Web application and Container image. Enter the image reference with its moving tag, such as
ghcr.io/acme/hello-django:main, and for a private package choose a GHCR credential that Can pull it, as pull a private GHCR image explains. - Add the variables. In the Variables tab, add
DJANGO_SECRET_KEYas a Sensitive variable, with a long random value such as the output ofpython -c "import secrets; print(secrets.token_urlsafe(50))". AddDJANGO_ALLOWED_HOSTSwith the hostname of the default route, and later of your custom domain, separated by commas. - Bind the database. Bind the PostgreSQL service to the application, which gives it
DATABASE_URL, the namedj-database-urlreads. - Set the command. Choose More actions, then Change configuration…, and enter
python manage.py migrate --noinputunder Pre-deploy commands. - Add the health check. In the Configuration tab, add a health check on
/health.
Each push then publishes a new image, and a last step of your workflow can deploy it by calling the application's deploy hook.
Deploy and verify
Choose Deploy, and once the release is active, open /admin/ on its default route over HTTPS. When something fails, the deployment Details and the Logs tab name the cause.
400 Bad Requeston every page means the hostname is missing fromDJANGO_ALLOWED_HOSTS.403withCSRF verification failedon a form meansSECURE_PROXY_SSL_HEADERis missing.KeyError: 'DJANGO_SECRET_KEY'means the variable is missing, and adding it then deploying again fixes it.- An admin without styles means
collectstaticdid not run or WhiteNoise is not inMIDDLEWARE.
To create your first admin user, open a terminal in a running replica and run python manage.py createsuperuser.
Keep files and run background work
Each deployment starts on a fresh filesystem, so store uploaded media in an S3-compatible bucket with django-storages, such as a bucket on your cluster. For a Celery or another queue consumer, create a worker from the same repository with its command as the entrypoint, and run a management command at set times as a scheduled job, with the same variables and binding as the web application.