Django with SQLite

This guide walks you through deploying a Django application with SQLite database to your own server using Haloy. Any Linux-based VPS or dedicated server will work.

The complete source code for this guide is available at: github.com/haloydev/examples/django

What You’ll Build

A full-stack web application using:

  • Django - Python web framework with batteries included
  • SQLite - Lightweight, file-based database
  • Gunicorn - Production-grade WSGI HTTP server
  • WhiteNoise - Static file serving for Python web apps
  • Haloy - Simple deployment to your own server

Prerequisites

  • Python 3.10+ installed
  • Haloy installed (Quickstart)
  • A linux server (VPS or dedicated server)
  • A domain or a subdomain
  • Basic familiarity with Python and Django

Project Setup

1. Create the Project

mkdir my-django-app cd my-django-app python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate

2. Install Dependencies

pip install django gunicorn whitenoise

3. Create Django Project and App

django-admin startproject myproject . python manage.py startapp polls

4. Create requirements.txt

Create requirements.txt:

django>=5.0,<6.1 gunicorn>=21.0 whitenoise>=6.6

Application Code

1. Configure Settings

Update myproject/settings.py for production deployment:

import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent # Security settings - use environment variables in production SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-change-this-in-production') DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes') ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') INSTALLED_APPS = [ 'polls.apps.PollsConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'myproject.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] WSGI_APPLICATION = 'myproject.wsgi.application' # Database - use environment variable for production path DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': Path(os.environ.get('DATABASE_PATH', BASE_DIR / 'db.sqlite3')), } } # Static files STATIC_URL = 'static/' STATIC_ROOT = BASE_DIR / 'staticfiles' # WhiteNoise for serving static files in production STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } # Security settings for reverse proxy (production) CSRF_TRUSTED_ORIGINS = [ f"https://{host}" for host in ALLOWED_HOSTS if host not in ('localhost', '127.0.0.1') ] CSRF_TRUSTED_ORIGINS += ['http://localhost:8000', 'http://127.0.0.1:8000'] SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

Key configuration points:

  • Environment variables for sensitive settings (SECRET_KEY, DEBUG, ALLOWED_HOSTS)
  • WhiteNoise middleware for serving static files in production
  • DATABASE_PATH environment variable allows configuring the database location for persistent volumes
  • CSRF_TRUSTED_ORIGINS and SECURE_PROXY_SSL_HEADER are required when running behind a reverse proxy like Haloy

2. Create Models

Update polls/models.py:

import datetime from django.contrib import admin from django.db import models from django.utils import timezone class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") def __str__(self): return self.question_text @admin.display( boolean=True, ordering="pub_date", description="Published recently?", ) def was_published_recently(self): now = timezone.now() return now - datetime.timedelta(days=1) <= self.pub_date <= now class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0) def __str__(self): return self.choice_text

3. Create Views

Update polls/views.py:

from django.db.models import F from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from django.views import generic from .models import Choice, Question class IndexView(generic.ListView): template_name = "polls/index.html" context_object_name = "latest_question_list" def get_queryset(self): return Question.objects.filter( pub_date__lte=timezone.now() ).order_by("-pub_date")[:5] class DetailView(generic.DetailView): model = Question template_name = "polls/detail.html" def get_queryset(self): return Question.objects.filter(pub_date__lte=timezone.now()) class ResultsView(generic.DetailView): model = Question template_name = "polls/results.html" def get_queryset(self): return Question.objects.filter(pub_date__lte=timezone.now()) def vote(request, question_id): question = get_object_or_404(Question, pk=question_id) try: selected_choice = question.choice_set.get(pk=request.POST["choice"]) except (KeyError, Choice.DoesNotExist): return render( request, "polls/detail.html", { "question": question, "error_message": "You didn't select a choice.", }, ) else: selected_choice.votes = F("votes") + 1 selected_choice.save() return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))

4. Configure URLs

Create polls/urls.py:

from django.urls import path from . import views app_name = "polls" urlpatterns = [ path("", views.IndexView.as_view(), name="index"), path("<int:pk>/", views.DetailView.as_view(), name="detail"), path("<int:pk>/results/", views.ResultsView.as_view(), name="results"), path("<int:question_id>/vote/", views.vote, name="vote"), ]

Update myproject/urls.py:

from django.contrib import admin from django.urls import include, path from django.views.generic import TemplateView urlpatterns = [ path("", TemplateView.as_view(template_name="home.html"), name="home"), path("polls/", include("polls.urls")), path("admin/", admin.site.urls), ]

5. Create Templates

First, create the template directories:

mkdir -p templates mkdir -p polls/templates/polls

Create templates/home.html:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Django Polls App</title> </head> <body> <h1>Django Polls</h1> <p>A simple polling application built with Django</p> <a href="{% url 'polls:index' %}">View Polls</a> <br> <a href="{% url 'admin:index' %}">Admin Panel</a> </body> </html>

Create polls/templates/polls/index.html:

{% load static %} <link rel="stylesheet" href="{% static 'polls/style.css' %}"> {% if latest_question_list %} <ul> {% for question in latest_question_list %} <li> <a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a> </li> {% endfor %} </ul> {% else %} <p>No polls are available.</p> {% endif %}

Create polls/templates/polls/detail.html:

<form action="{% url 'polls:vote' question.id %}" method="post"> {% csrf_token %} <fieldset> <legend><h1>{{ question.question_text }}</h1></legend> {% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %} {% for choice in question.choice_set.all %} <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}"> <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br> {% endfor %} </fieldset> <input type="submit" value="Vote"> </form>

Create polls/templates/polls/results.html:

<h1>{{ question.question_text }}</h1> <ul> {% for choice in question.choice_set.all %} <li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li> {% endfor %} </ul> <a href="{% url 'polls:detail' question.id %}">Vote again?</a>

6. Configure Admin

Update polls/admin.py:

from django.contrib import admin from .models import Choice, Question class ChoiceInline(admin.TabularInline): model = Choice extra = 3 class QuestionAdmin(admin.ModelAdmin): fieldsets = [ (None, {"fields": ["question_text"]}), ("Date information", {"fields": ["pub_date"], "classes": ["collapse"]}), ] inlines = [ChoiceInline] list_display = ["question_text", "pub_date", "was_published_recently"] list_filter = ["pub_date"] search_fields = ["question_text"] admin.site.register(Question, QuestionAdmin)

7. Create Static Files

First, create the static directory:

mkdir -p polls/static/polls

Create polls/static/polls/style.css:

li a { color: green; } body { background: white; }

8. Generate and Run Migrations

First, create the migration files for your models:

python manage.py makemigrations polls

Then apply the migrations to create the database tables:

python manage.py migrate

Docker Configuration

1. Create Dockerfile

Create Dockerfile:

FROM python:3.14-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ DJANGO_DEBUG=False \ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN python manage.py collectstatic --noinput RUN mkdir -p /app/data EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 CMD [ "sh", "-c", "python manage.py migrate --noinput && gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 myproject.wsgi:application" ]

Key points:

  • Uses python:3.13-slim for a smaller image
  • Collects static files at build time
  • Runs migrations at container startup (safe because Django migrations are idempotent)
  • Uses Gunicorn as the production WSGI server

Note: The worker/thread configuration is conservative to work well with SQLite’s write concurrency limits. For servers with more CPU cores, you can increase workers, but be aware that SQLite may experience locking with too many concurrent writes.

2. Create .dockerignore

Create .dockerignore:

.git .gitignore __pycache__ *.py[cod] *$py.class venv .venv .env .env.local db.sqlite3 *.sqlite3 staticfiles .vscode .idea *.md .DS_Store

Note: If you plan to add this project to a git repository it would also be beneficial with a .gitignore file. Check out the source code for an example.

Haloy Configuration

Create haloy.yaml:

This file tells the haloy CLI tool how to deploy your app. Change the domain to a domain/subdomain you own and make sure to point an A DNS record to the IP-address of your server. Remember to update the DJANGO_ALLOWED_HOSTS environment variable with your domain.

name: my-django-app server: your-server.haloy.dev domains: - domain: my-app.example.com port: 8000 env: - name: DJANGO_DEBUG value: "False" - name: DJANGO_SECRET_KEY value: "your-production-secret-key-change-this" - name: DJANGO_ALLOWED_HOSTS value: "my-app.example.com,localhost,127.0.0.1" - name: DATABASE_PATH value: "/app/data/db.sqlite3" - name: DJANGO_SUPERUSER_USERNAME value: "admin" - name: DJANGO_SUPERUSER_EMAIL value: "admin@example.com" - name: DJANGO_SUPERUSER_PASSWORD value: "your-secure-password" volumes: - "db-data:/app/data"

Configuration Explained

FieldDescription
nameUnique identifier for your application
serverYour Haloy server domain
domainsPublic domain(s) for your app (HTTPS is automatic)
portThe port your app listens on inside the container (Gunicorn uses 8000)
envEnvironment variables passed to your container
volumesPersistent storage - critical for SQLite data

Environment Variables Explained

VariableDescription
DJANGO_DEBUGSet to “False” in production
DJANGO_SECRET_KEYSecret key for cryptographic signing (generate a secure one!)
DJANGO_ALLOWED_HOSTSComma-separated list of allowed hostnames
DATABASE_PATHPath to SQLite database file (inside the volume)
DJANGO_SUPERUSER_USERNAMEAdmin username for non-interactive superuser creation
DJANGO_SUPERUSER_EMAILAdmin email
DJANGO_SUPERUSER_PASSWORDAdmin password

Generate a Secure Secret Key

Generate a production secret key:

python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Copy the generated key and replace your-production-secret-key-change-this in your haloy.yaml.

Managing Secrets Securely

For production deployments, consider using Haloy’s built-in secret management instead of hardcoding secrets in haloy.yaml. This is especially important for sensitive values like DJANGO_SECRET_KEY and DJANGO_SUPERUSER_PASSWORD.

Haloy supports 1Password integration to securely manage credentials:

name: my-django-app server: your-server.haloy.dev domains: - domain: my-app.example.com port: 8000 env: - name: DJANGO_DEBUG value: "False" - name: DJANGO_SECRET_KEY secret: provider: 1password reference: "op://Private/django-app/secret_key" - name: DJANGO_ALLOWED_HOSTS value: "my-app.example.com,localhost,127.0.0.1" - name: DATABASE_PATH value: "/app/data/db.sqlite3" - name: DJANGO_SUPERUSER_USERNAME value: "admin" - name: DJANGO_SUPERUSER_EMAIL value: "admin@example.com" - name: DJANGO_SUPERUSER_PASSWORD secret: provider: 1password reference: "op://Private/django-app/superuser_password" volumes: - "db-data:/app/data"

Prerequisites for secret management:

  • 1Password CLI (op) installed and authenticated
  • Secrets stored in a 1Password vault with the referenced field names

See the Secret Providers documentation for complete setup instructions.

Volume Configuration

The volumes configuration is critical for SQLite. See Volumes for more details on persistent storage.

volumes: - "db-data:/app/data"

This creates a named volume db-data mounted at /app/data inside the container. The DATABASE_PATH points to a file in this directory, ensuring your database persists across deployments and container restarts.

Deploy

1. Test Locally

Before deploying, verify everything works:

python manage.py runserver

Visit http://localhost:8000 to verify the app is working.

2. Deploy with Haloy

haloy deploy

Haloy will:

  1. Build your Docker image locally
  2. Push it to your server
  3. Run the container with your configuration
  4. Set up HTTPS automatically
  5. Route traffic to your app

3. Create Admin Superuser

After deployment, create the admin superuser:

haloy exec -- python manage.py createsuperuser --noinput

This uses the DJANGO_SUPERUSER_* environment variables defined in haloy.yaml.

4. Verify Deployment

# Check status haloy status # View logs haloy logs

Your app should now be live at your configured domain. Access the admin panel at https://my-app.example.com/admin/.

Production Considerations

Database Backups

SQLite stores all data in a single file. To back up your database:

haloy exec -- cp /app/data/db.sqlite3 /app/data/backup-$(date +%Y%m%d).db

Consider setting up automated backups using a cron job or scheduled task.

Monitoring

View your application logs:

# Stream logs haloy logs # Check application status haloy status

Running Django Management Commands

You can run any Django management command using haloy exec:

# Run migrations manually haloy exec -- python manage.py migrate # Create a superuser interactively (if needed) haloy exec -- python manage.py createsuperuser --noinput # Collect static files haloy exec -- python manage.py collectstatic --noinput # Open Django shell haloy exec -- python manage.py shell

Troubleshooting

CSRF Verification Failed

If you see “CSRF verification failed” when logging into admin:

  1. Ensure DJANGO_ALLOWED_HOSTS includes your domain
  2. Verify CSRF_TRUSTED_ORIGINS is configured in settings (should be automatic if you followed this guide)
  3. Check that SECURE_PROXY_SSL_HEADER is set

Database Not Persisting

Ensure your volumes configuration matches your DATABASE_PATH:

env: - name: DATABASE_PATH value: "/app/data/db.sqlite3" # Must be inside the volume mount volumes: - "db-data:/app/data" # Volume mounted here

Verify the database file exists:

haloy exec -- ls -la /app/data/

Bad Request (400)

This usually means ALLOWED_HOSTS doesn’t include your domain:

  1. Check DJANGO_ALLOWED_HOSTS in haloy.yaml includes your domain
  2. Redeploy after making changes

Migration Errors

If migrations fail at startup:

haloy logs

Common issues:

  • Database file permissions
  • Volume not mounted correctly
  • Missing migration files in Docker image

Scaling Limitations

SQLite is designed for single-server deployments. If you need to run multiple replicas of your application, consider:

  1. Switch to PostgreSQL - Django supports it natively with psycopg2
  2. Use a managed database - Cloud providers offer managed PostgreSQL/MySQL

For most applications, a single replica with SQLite can handle significant traffic.