Django with PostgreSQL on a Free Tier in 2026: What Actually Works
Django is easy to run locally and surprisingly easy to run badly in production: the dev server left on, static files served by Python, a SQLite file that vanishes on the next deploy. This guide takes a standard Django project to a live URL on a free container tier, with a real PostgreSQL database, and is honest about where "free" stops.
Everything below was tested with Django 5.2 LTS (supported until April 2028) on Python 3.12. Django 6.1 is the current release; nothing here is version-specific.
What "production-ready" means for a small Django app
- Gunicorn serves the app, not
manage.py runserver. - WhiteNoise serves static files from the container, so you do not need a CDN or a second service for CSS and JavaScript.
- Settings come from environment variables:
SECRET_KEY,DEBUG,ALLOWED_HOSTS,DATABASE_URL. - PostgreSQL, because the container filesystem is ephemeral. A SQLite file works for a demo, but every deploy and every wake-up starts from a fresh filesystem, so the data is gone.
Step 1: the four files
requirements.txt (pin your own versions; these are the packages that matter):
django>=5.2,<5.3
gunicorn
whitenoise
dj-database-url
psycopg[binary]settings.py, the parts that change:
import os
import dj_database_url
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS if h]
DATABASES = {
"default": dj_database_url.config(
default="sqlite:///db.sqlite3", conn_max_age=600
)
}
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# ...the rest of your middleware
]
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}Dockerfile. SnapDeploy generates one when it finds manage.py, but writing your own gives you control over the Python version and the start command:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN SECRET_KEY=build python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2 --timeout 60"]Replace config.wsgi with your project's WSGI module. The collectstatic step runs at build time with a throwaway secret so the image already contains the compressed static files. Migrations run at start; with a single container that is safe, and it means a deploy that adds a column never leaves the app pointing at an old schema.
.dockerignore:
.git
.venv
venv
__pycache__
*.pyc
db.sqlite3
.env
staticfilesStep 2: a PostgreSQL database
No free tier we know of gives you a permanent, always-on Postgres for nothing, so pick the trade-off that fits:
| Option | Cost | The catch |
|---|---|---|
| SnapDeploy DB Sprint Pack | $1 for 12 hours (PostgreSQL 16, 1 GB RAM, 5 GB storage) | A trial, not a home. Data is kept 24 hours after expiry; convert to Mini ($29/month) to keep it. |
| SnapDeploy PostgreSQL Mini | $29/month | Not free. Same dashboard as the container, connection string injected, daily backups. |
| Neon (free plan) | Free | 0.5 GB of storage and 100 compute-hours a month; the database suspends when idle and resumes on the next query. |
| Supabase (free plan) | Free | 500 MB, two projects, paused after seven days without activity, no automated backups. |
| Render (free Postgres) | Free | 1 GB; expires 30 days after creation, then a 14-day grace period before deletion. |
Whichever you choose, the app only needs one thing: a DATABASE_URL in the form postgresql://user:password@host:5432/dbname. With a SnapDeploy add-on that variable is injected into the container for you; with an external provider you paste it into the container's environment variables.
Step 3: deploy
- Push the project to GitHub.
- In the SnapDeploy dashboard, open Containers and choose Deploy from GitHub. Connect the repository. If it has a Dockerfile, that is used; otherwise the generated Python Dockerfile installs
requirements.txtand runs Gunicorn against the WSGI module it finds. - Set the environment variables:
SECRET_KEY(a long random string),DEBUG=false,ALLOWED_HOSTSwith your container's hostname, andDATABASE_URLunless a SnapDeploy database is attached. - Deploy. The first build takes a few minutes. The URL is on
containers.snapdeploy.app.
Two things people forget: ALLOWED_HOSTS must contain the exact hostname, or every request is a 400; and CSRF_TRUSTED_ORIGINS must include the https:// origin, or every form post is a 403 behind the proxy.
Where free stops
- The free container is 512 MB and 0.25 vCPU. Two Gunicorn workers are plenty; four will swap.
- Free containers sleep after 15 minutes without traffic and wake in about 60 seconds. Django's first request after a wake also warms its own caches, so budget for a slow first page.
- 100 container-hours a month. An app that is visited a few times a day uses a fraction of that; an app you keep pinging to stay awake burns through it.
- Custom domains need an Always-On container ($12/month), which also removes the sleeping.
- Media uploads: the filesystem is ephemeral. Put user uploads in object storage (S3 or compatible), not on disk.
Health check and logs
Add a tiny view that returns 200 at /health and, if you like, runs SELECT 1 against the database. It costs nothing and it is the first thing you will look at when something breaks. Gunicorn's access log goes to stdout, which is what the container logs show.
from django.db import connection
from django.http import JsonResponse
def health(request):
with connection.cursor() as cur:
cur.execute("SELECT 1")
return JsonResponse({"status": "ok"})Test the image before you push
Most "it works locally but not in the container" problems are visible in thirty seconds on your own machine. Build and run the same image the platform will run:
docker build -t myapp .
docker run --rm -p 8000:8000 -e SECRET_KEY=dev -e DEBUG=false -e ALLOWED_HOSTS=localhost myapp
curl -i http://localhost:8000/healthIf this returns 200 with SQLite, the only thing the container host adds is the real DATABASE_URL. If it returns 400, the hostname is not in ALLOWED_HOSTS; if the static files are missing, collectstatic did not run in the build.
Environment variables at a glance
| Variable | Value | If you forget it |
|---|---|---|
SECRET_KEY | 50+ random characters, never the one from the repository | The app refuses to start (our settings read it without a default, on purpose). |
DEBUG | false | Tracebacks with settings values are shown to visitors. |
ALLOWED_HOSTS | The container hostname, comma-separated if several | Every request is a 400. |
DATABASE_URL | Injected by a SnapDeploy add-on, or pasted from Neon, Supabase or Render | The app silently uses SQLite and loses data on the next deploy. |
Background work on a container that sleeps
A free container sleeps after 15 minutes without HTTP traffic, so a management command you run "every hour" from inside the app will not run while the container is asleep, and a second container dedicated to a worker has no HTTP traffic at all, so it is treated as idle and slept too. For a free-tier Django app the honest options are: do periodic work when a request arrives (a lightweight check at the top of a view), trigger it from outside with a scheduled HTTP call from a service that runs elsewhere, or move to Always-On ($12/month), where the container never sleeps and a Celery worker or a cron loop behaves as you expect.
Try it: the Django deployment page has the same steps in short form. The free tier is 4 containers, 100 container-hours a month, no card; a $1 DB Sprint Pack gives you a full PostgreSQL for 12 hours when you want to test the whole stack.