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
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
pip install django gunicorn whitenoise
3. Create Django Project and App
django-admin startproject myproject .
python manage.py startapp polls
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
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'
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
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,)))
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"),
]
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),
]
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
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>
<!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 %}
{% 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>
<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>
<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)
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
mkdir -p polls/static/polls
Create polls/static/polls/style.css:
li a {
color: green;
}
body {
background: white;
}
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
python manage.py makemigrations polls
Then apply the migrations to create the database tables:
python manage.py migrate
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" ]
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-slimfor 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
.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"
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
| Field | Description |
|---|---|
name | Unique identifier for your application |
server | Your Haloy server domain |
domains | Public domain(s) for your app (HTTPS is automatic) |
port | The port your app listens on inside the container (Gunicorn uses 8000) |
env | Environment variables passed to your container |
volumes | Persistent storage - critical for SQLite data |
Environment Variables Explained
| Variable | Description |
|---|---|
DJANGO_DEBUG | Set to “False” in production |
DJANGO_SECRET_KEY | Secret key for cryptographic signing (generate a secure one!) |
DJANGO_ALLOWED_HOSTS | Comma-separated list of allowed hostnames |
DATABASE_PATH | Path to SQLite database file (inside the volume) |
DJANGO_SUPERUSER_USERNAME | Admin username for non-interactive superuser creation |
DJANGO_SUPERUSER_EMAIL | Admin email |
DJANGO_SUPERUSER_PASSWORD | Admin 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())"
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"
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"
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
python manage.py runserver
Visit http://localhost:8000 to verify the app is working.
2. Deploy with Haloy
haloy deploy
haloy deploy
Haloy will:
- Build your Docker image locally
- Push it to your server
- Run the container with your configuration
- Set up HTTPS automatically
- Route traffic to your app
3. Create Admin Superuser
After deployment, create the admin superuser:
haloy exec -- python manage.py createsuperuser --noinput
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
# 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
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
# 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
# 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:
- Ensure
DJANGO_ALLOWED_HOSTSincludes your domain - Verify
CSRF_TRUSTED_ORIGINSis configured in settings (should be automatic if you followed this guide) - Check that
SECURE_PROXY_SSL_HEADERis 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
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/
haloy exec -- ls -la /app/data/
Bad Request (400)
This usually means ALLOWED_HOSTS doesn’t include your domain:
- Check
DJANGO_ALLOWED_HOSTSinhaloy.yamlincludes your domain - Redeploy after making changes
Migration Errors
If migrations fail at startup:
haloy logs
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:
- Switch to PostgreSQL - Django supports it natively with
psycopg2 - Use a managed database - Cloud providers offer managed PostgreSQL/MySQL
For most applications, a single replica with SQLite can handle significant traffic.