# How to deploy Django to a VPS with Gunicorn

Source: https://tenkai.cloud/help/deploy-django

> 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.

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](https://tenkai.cloud/help/projects-and-clusters).

## Add the production packages

Add these packages to `requirements.txt`, which the image installs.

```text
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.

```python
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.

```python
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.

```python
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`.

```python
'whitenoise.middleware.WhiteNoiseMiddleware',
```

Then tell `collectstatic` where to write the files and how to store them.

```python
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`.

```python
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.

```dockerfile
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.

```text
.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.

```sh
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.

```sh
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](https://tenkai.cloud/help/publish-image-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

1. **Create the database.** [Create a PostgreSQL managed service](https://tenkai.cloud/help/managed-services#create-the-service) and wait until it is running.
2. **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](https://tenkai.cloud/help/pull-private-ghcr-image) explains.
3. **Add the variables.** In the **Variables** tab, add `DJANGO_SECRET_KEY` as a **Sensitive** variable, with a long random value such as the output of `python -c "import secrets; print(secrets.token_urlsafe(50))"`. Add `DJANGO_ALLOWED_HOSTS` with the hostname of the default route, and later of your custom domain, separated by commas.
4. **Bind the database.** [Bind the PostgreSQL service](https://tenkai.cloud/help/managed-services#bind-an-application) to the application, which gives it `DATABASE_URL`, the name `dj-database-url` reads.
5. **Set the command.** Choose **More actions**, then **Change configuration…**, and enter `python manage.py migrate --noinput` under **Pre-deploy commands**.
6. **Add the health check.** In the **Configuration** tab, add a [health check](https://tenkai.cloud/help/blue-green-deployments#the-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](https://tenkai.cloud/help/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 Request` on every page means the hostname is missing from `DJANGO_ALLOWED_HOSTS`.
- `403` with `CSRF verification failed` on a form means `SECURE_PROXY_SSL_HEADER` is missing.
- `KeyError: 'DJANGO_SECRET_KEY'` means the variable is missing, and adding it then deploying again fixes it.
- An admin without styles means `collectstatic` did not run or WhiteNoise is not in `MIDDLEWARE`.

To create your first admin user, [open a terminal](https://tenkai.cloud/help/resource-logs-and-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](https://tenkai.cloud/help/object-storage). For a Celery or another queue consumer, create a [worker](https://tenkai.cloud/help/workers) from the same repository with its command as the entrypoint, and run a management command at set times as a [scheduled job](https://tenkai.cloud/help/scheduled-jobs), with the same variables and binding as the web application.
