Deploy Django with Gunicorn, Postgres and automatic HTTPS on a $5 VPS. Includes Dockerfile, static files and migration strategy.
The stack, and why each piece
A production Django deployment on a VPS has four moving parts: Gunicorn running your WSGI app (the dev server is explicitly not for production), WhiteNoise serving static files from inside the app (eliminating the separate nginx container the old guides required), Postgres as a managed database on the same server, and a reverse proxy in front handling TLS.
This shape keeps the whole stack on one machine, which means your ORM queries travel microseconds instead of a cross-provider network hop, typically the single biggest performance difference people notice after leaving a PaaS.
The Dockerfile
PYTHONUNBUFFERED=1 makes logs appear in real time instead of after buffer flushes
collectstatic at build time bakes static files into the image; WhiteNoise serves them with proper caching headers
Workers rule of thumb: 2 x CPU cores + 1; three workers suits a 1 to 2 vCPU VPS
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "config.wsgi", "-b", "0.0.0.0:8000", \
"--workers", "3", "--timeout", "60", "--access-logfile", "-"]
Production settings that matter
Djangoβs deployment checklist (manage.py check --deploy) is worth running, but these are the essentials:
DEBUG=False and SECRET_KEY from environment variables, never in the repo
ALLOWED_HOSTS=["app.example.com"], Django refuses other Host headers
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") so Django knows requests arrived over TLS at the proxy
CSRF_TRUSTED_ORIGINS=["https://app.example.com"] for Django 4+ form posts behind a proxy
DATABASE_URL parsed with dj-database-url, pointing at the private-network Postgres hostname
Media uploads to S3-compatible storage via django-storages, container disk is ephemeral by design
Provision Postgres and connect
Create a managed Postgres service on the same server; with Peon that is one click and includes scheduled dumps to S3-compatible storage with retention. The database never publishes a public port: your app reaches it by service name over the private Docker network, so DATABASE_URL looks like postgres://app:password@postgres-abc123:5432/appdb.
Migrations on every deploy
Run python manage.py migrate as a release step before the new container takes traffic; platforms expose this as a pre-start or release command. Because zero-downtime deploys briefly run old and new code side by side, keep each migration backward-compatible with the previous release: add columns as nullable first, backfill, then tighten in a later deploy. Never rename a column in one step on a live app.
Background tasks and cron
Celery workers deploy as a second service from the same image with a different command (celery -A config worker), sharing environment variables and the Docker network with your web service. Redis as the broker is a one-click template. Periodic jobs (clearing sessions, sending digests) run as scheduled tasks executing manage.py commands, replacing the crontab you would otherwise maintain by hand.
Cost recap
Web, worker, Postgres and Redis on one $8 4 GB VPS, plus $2 for the project: about $10 a month for a production Django setup that the equivalent managed stack (Heroku dynos plus Standard Postgres plus Redis) prices at $100 or more. The workflow, push to deploy, logs in a dashboard, one-click rollback, is the same.
Top comments (0)