Deploy a Django app
Your Django app on a computer of its own. git push to deploy.
Shelley handles the setup. About 10 minutes.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/django/prompt.txt | ssh exe.dev new --prompt=/dev/stdinYour Django app on a VM of its own: git push to deploy, HTTPS from
exe.dev, the database and uploads on a persistent disk, and everything
back after a reboot. Unlike a container platform, the disk is real, so
SQLite and MEDIA_ROOT just work; nothing sleeps or cold-starts; and
there's a shell and an agent on the box when something needs fixing.
It stays private until you share it, and exe.dev's login can stand in for
your own: open /admin/ and you're already signed in.
The setup is the classic one: gunicorn runs Django
behind nginx, which serves static files and uploads from disk and buffers
slow clients, as gunicorn's docs recommend. uv
installs Python and your dependencies. Each git push builds a new release
next to the running one, so a broken build never takes the site down.
What you get#
- Your Django app, or a new Django 6.1 project, served by gunicorn behind nginx on port 8000, under systemd and enabled to start after a reboot.
git push exe maindeploys: the pre-receive hook in~/app.gitbuilds and migrates each push in a new~/releasesdirectory and switches~/appto it only if every step passes, so a failing build rejects the push and the running release stays.- Production settings for exe.dev's proxy in
~/app-settingsand~/app.env, with DEBUG off, a generated secret key, HTTPS detected, and allowed hosts and CSRF origins set;manage check --deployreports no issues. - SQLite in WAL mode at
~/data/db.sqlite3, and uploads in~/data/mediaserved at/media/. - The owner's exe.dev account is a Django superuser and opens
/admin/without a password. - Nightly backups in
~/backups, with a first one taken and checked.
What's running, and where
https://my-django.exe.xyz/
your app, until you make it public
https://my-django.exe.xyz/admin/
the Django admin, signed in with your exe.dev account
my-django.exe.xyz:app.git
where you git push to deploy
Deploy with Shelley#
Click Deploy and Shelley will set up Django on a new VM, then check that it works. No config-file wrangling required.
Have these ready; Shelley asks for them:
REPO: the GitHub repository to deploy, as owner/name or a URL, ornewto start a fresh Django app
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/django/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhen it's done, run this on your computer (only you can):
git clone -o exe my-django.exe.xyz:app.git my-django # if you started a new project
git remote add exe my-django.exe.xyz:app.git # or, in your own repository's checkoutWhat Shelley is told
REPO=<fill-this-in>
Set up Django on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/django.md from top to bottom.
The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Django app
Ask me for any that still say <fill-this-in> before you need them.
The recipe's example VM name is my-django. This VM's name is the output of `hostname`: use it wherever the recipe writes my-django. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Your Django app, or a new Django 6.1 project, served by gunicorn behind nginx on port 8000, under systemd and enabled to start after a reboot.
- `git push exe main` deploys: the pre-receive hook in `~/app.git` builds and migrates each push in a new `~/releases` directory and switches `~/app` to it only if every step passes, so a failing build rejects the push and the running release stays.
- Production settings for exe.dev's proxy in `~/app-settings` and `~/app.env`, with DEBUG off, a generated secret key, HTTPS detected, and allowed hosts and CSRF origins set; `manage check --deploy` reports no issues.
- SQLite in WAL mode at `~/data/db.sqlite3`, and uploads in `~/data/media` served at `/media/`.
- The owner's exe.dev account is a Django superuser and opens `/admin/` without a password.
- Nightly backups in `~/backups`, with a first one taken and checked.
Read the whole recipe, including the closed details, and follow "Set it
up by hand" in order, skipping "Create the VM"; the walkthrough above it
is for the owner. REPO=new takes "Start a new project"; anything else
takes "Bring your repository". If cloning from GitHub fails, the
repository is private: give the owner the `ssh exe.dev integrations add
github` command from that step, with this VM's name and the repository,
wait for them, and clone from github.int.exe.xyz.
Never overwrite an existing ~/app.env. Don't commit to the owner's code,
and don't push test commits to ~/app.git: read the hook to check it.
If their repository doesn't fit the deploy hook (manage.py not at the
top, Pipenv, a custom user model without a username field), adapt the
hook or the command and tell the owner what you changed.
Leave the VM private and don't reboot: check that app.service, nginx and
app-backup.timer are enabled instead. Skip the later Restore and Update
instructions; the restore check in setup uses a scratch copy only.
The URLs to give are https://<vm>.exe.xyz/ and /admin/. Of the two
commands at the end, give the owner only the one that fits REPO,
followed by `git push exe main`, and say what you adapted, if anything.
Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
git clone -o exe my-django.exe.xyz:app.git my-django # if you started a new project
git remote add exe my-django.exe.xyz:app.git # or, in your own repository's checkout
Also at /deploy/django/prompt.txt. The recipe it follows is /deploy/django.md.
Open your app#
When Shelley finishes, open https://my-django.exe.xyz/. Sign in to
exe.dev if asked. A new project says hello and shows who you're signed in
as; your own app shows its home page.
Then open https://my-django.exe.xyz/admin/. You're signed in already,
as a superuser named after your exe.dev email address. There's no Django
password: exe.dev's proxy tells Django who you are on every request (see
Sign in with exe.dev).
Deploy from your computer#
On your computer, connect your code to the VM. If you started a new
project, clone it; the remote is called exe:
git clone -o exe my-django.exe.xyz:app.git my-django
cd my-django
uv run manage.py migrate && uv run manage.py runserver
That runs it locally with the project's own settings, at
http://127.0.0.1:8000/. If you brought a repository, add the remote to
your existing checkout instead:
git remote add exe my-django.exe.xyz:app.git
Commit a change and push it:
git push exe main
The deploy streams back into your terminal: dependencies, checks, migrations, static files, then the restart.
remote: -----> Building 3f2a9c1
remote: Installed 4 packages in 12ms
remote: -----> Checking, migrating, collecting static files
remote: System check identified no issues (2 silenced).
remote: Operations to perform: ...
remote: -----> Starting 3f2a9c1
remote: -----> Deployed 3f2a9c1: https://my-django.exe.xyz/
If any step fails (a missing dependency, a broken import, a migration
error, a new release that won't answer), the push is rejected, as on
Heroku: the previous release keeps serving, and main on the VM stays at
the commit that's running. Fix it and push again. To deploy another branch, push it to main:
git push exe my-branch:main. To roll back, push an older commit:
git push -f exe 3f2a9c1:main. Migrations run before the new release
goes live and stay applied if it's rolled back, so the running code must
cope with them: add a column in one deploy and use it in the next.
Tip
If your code lives on GitHub, keep pushing there too: GitHub stays the source of truth, and
exeis where you deploy, likeherokuused to be. A new project lives only on the VM and your computer until you push it somewhere else; the nightly backups include it.
What's different on the server#
Your repository doesn't change. On the VM, Django loads
~/app-settings/exe_settings.py, which imports your project's settings
and then sets what the server decides: DEBUG = False, the secret key,
the hosts, HTTPS behind exe.dev's proxy, SQLite in ~/data, email, and
logging to the journal. Locally, runserver keeps using your own settings.
- Secrets and per-server values live in
~/app.env(mode 600). To add one, saySTRIPE_KEY=..., add a line there and read it withos.environ["STRIPE_KEY"]in your settings, then restart:sudo systemctl restart app. - Logs:
journalctl -u app -f. Errors and tracebacks land there too. - Management commands:
manage shell,manage dbshell,manage createsuperuser, all against the live database. - Email goes out through exe.dev's email gateway: plain text, to you, your team, and people you've shared the VM with. When a page fails with a server error, Django emails you the traceback. To email anyone else (password resets for the public, say), configure an email provider in your settings; the VM then leaves your email settings alone.
Your data#
Everything worth keeping is in ~/data: the database at
~/data/db.sqlite3 and uploads in ~/data/media. A backup runs every
night into ~/backups, with the database, uploads, ~/app.env, the
settings, and the git repository; it keeps a week. Keep a copy off the
VM too. On your computer:
scp -r my-django.exe.xyz:backups .
The backups hold your secret key, so keep them private. To restore one, see Back up and restore.
Sign in with exe.dev#
exe.dev's proxy adds an X-ExeDev-Email header to every request from a
signed-in visitor (Login with exe). The VM's
settings add a small RemoteUserMiddleware that signs that person in as
the Django user whose username is their email address. People without
such a user stay anonymous, and your app's own login keeps working.
Signing out happens at exe.dev, not in Django.
To let a colleague in, share the VM with them. On your computer:
ssh exe.dev share add my-django colleague@example.com
Then, in the admin, add a user whose username is their email address, and
tick Staff status and the permissions they need. For an internal tool
where everyone you share with should get a user automatically, set
create_unknown_user = True in ~/app-settings/exe_login.py and run
sudo systemctl restart app. New users start without permissions. Turn
it back off before you make the site public, or anyone with an exe.dev
account gets a user.
Make it public#
The app is private until you say otherwise. On your computer:
ssh exe.dev share set-public my-django
Now anyone can open it, anonymously. exe.dev only sends the header for
visitors who sign in on this host, so to reach the admin without a
password, open
https://my-django.exe.xyz/__exe.dev/login?redirect=/admin/. To make it
private again: ssh exe.dev share set-private my-django.
Use your own domain#
Point a CNAME at my-django.exe.xyz and register the domain with
exe.dev, as in custom domains. On your computer:
ssh exe.dev domain add my-django app.example.com
Then, on the VM, add the domain to DJANGO_ALLOWED_HOSTS in ~/app.env
(comma-separated, no spaces) and restart. Django builds
CSRF_TRUSTED_ORIGINS from the same list.
sed -i 's/^DJANGO_ALLOWED_HOSTS=.*/&,app.example.com/' ~/app.env
sudo systemctl restart app
Background tasks and scheduled jobs#
Django 6 has a Tasks framework
built in: decorate a function with @task and call .enqueue(). Django
only ships backends for tests, so add
django-tasks-db, which
keeps the queue in your database. No Redis needed.
In your project, on your computer:
uv add django-tasks-db
In settings.py, add "django_tasks_db" to INSTALLED_APPS and:
TASKS = {"default": {"BACKEND": "django_tasks_db.DatabaseBackend"}}
A task, in any app's tasks.py:
from django.tasks import task
@task
def send_welcome(user_id):
...
# in a view: send_welcome.enqueue(user.id)
Commit and git push exe main. Then, on the VM, run the worker as
app-worker.service. The deploy hook restarts it with every release.
sudo tee /etc/systemd/system/app-worker.service >/dev/null <<'EOF'
[Unit]
Description=Django background tasks (db_worker)
After=network.target
[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
ExecStart=/home/exedev/app/.venv/bin/python manage.py db_worker
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-worker
systemctl status app-worker --no-pager
For jobs on a schedule, use a systemd timer that runs a management command. This one clears expired sessions every night:
sudo tee /etc/systemd/system/app-clearsessions.service >/dev/null <<'EOF'
[Unit]
Description=Clear expired Django sessions
[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/.local/bin/manage clearsessions
EOF
sudo tee /etc/systemd/system/app-clearsessions.timer >/dev/null <<'EOF'
[Unit]
Description=Clear expired Django sessions nightly
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-clearsessions.timer
Shelley is on call#
Shelley lives on the VM at https://my-django.shelley.exe.xyz/. It can
read journalctl -u app, run manage commands, roll back a release, or
make a change in a clone of ~/app.git and push it. Ask it things like
"why is the contact form returning 500?".
The nuts and bolts#
Happy with what Deploy made? You can stop here. These are the same steps Shelley follows, and the commands for later.
Set it up by hand
Create the VM#
On your computer (pick another name if my-django is taken):
ssh exe.dev new --name=my-django
ssh my-django.exe.xyz
Everything else runs on the VM, as exedev.
Prepare the VM#
The layout: code is pushed to ~/app.git, each deploy unpacks into
~/releases/<time>-<commit>, and ~/app points at the live one. Data
lives in ~/data. nginx (user www-data) serves static files and
uploads, so it may pass through your home directory without listing it;
the database stays private to exedev.
chmod 711 ~
mkdir -p ~/releases ~/app-settings ~/.local/bin
install -d -m 711 ~/data
install -d -m 755 ~/data/media
[ -e ~/data/db.sqlite3 ] || install -m 600 /dev/null ~/data/db.sqlite3
uv --version
uv comes with the VM. It installs the Python version your project asks
for (in .python-version or requires-python) the first time you deploy.
Get your code#
Pick one. Both leave a git checkout in ~/seed, which the first deploy
pushes from.
Bring your repository
Set REPO to your repository and clone it. Public repositories clone
over HTTPS:
REPO=owner/name
slug=$(echo "$REPO" | sed -E 's#^(https://github\.com/|git@github\.com:)##; s#\.git$##; s#/$##')
git clone "https://github.com/$slug.git" ~/seed
For a private repository, connect it with the GitHub integration instead, so no token lands on the VM. On your computer:
ssh exe.dev integrations add github --name my-django-repo --repository owner/name --attach vm:my-django
Then clone through the integration, on the VM:
git clone "https://github.int.exe.xyz/$slug.git" ~/seed
The deploy hook expects manage.py at the top of the repository, and
either uv.lock, requirements.txt, or a pyproject.toml with a
[project] table. uv picks the Python version from .python-version,
requires-python, or Heroku's runtime.txt. Check:
cd ~/seed && ls -A | grep -xE 'manage.py|uv.lock|requirements.txt|pyproject.toml|.python-version|runtime.txt'
The app gets a fresh SQLite database. To bring data from another
database, run manage dumpdata there and manage loaddata here after
the first deploy.
Start a new project
Create a project with uv and django-admin startproject, add a home
page, and commit:
mkdir ~/seed && cd ~/seed
uv init --quiet --no-package --python 3.14 --name mysite
rm main.py
uv add --quiet 'django~=6.1.0' gunicorn
uv run django-admin startproject mysite .
mkdir templates
sed -i "s|'DIRS': \[\],|'DIRS': [BASE_DIR / 'templates'],|" mysite/settings.py
printf '%s\n' db.sqlite3 /staticfiles/ /media/ >> .gitignore
The home page, and a short README:
cd ~/seed
cat > mysite/urls.py <<'EOF'
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
urlpatterns = [
path("", TemplateView.as_view(template_name="home.html"), name="home"),
path("admin/", admin.site.urls),
]
EOF
cat > templates/home.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mysite</title>
</head>
<body style="font: 18px/1.5 system-ui, sans-serif; max-width: 36rem; margin: 4rem auto; padding: 0 1rem">
<h1>Hello from Django</h1>
{% if user.is_authenticated %}
<p>You're signed in as <strong>{{ user.get_username }}</strong>.</p>
{% else %}
<p>You're not signed in to this app.</p>
{% endif %}
<p>Edit <code>templates/home.html</code>, commit, and run
<code>git push exe main</code>. The <a href="{% url 'admin:index' %}">admin</a>
is ready.</p>
</body>
</html>
EOF
cat > README.md <<'EOF'
# mysite
Run it locally:
uv run manage.py migrate
uv run manage.py runserver
Deploy: `git push exe main`
EOF
uv run manage.py check
Expect System check identified no issues. Commit, as the VM's owner:
cd ~/seed
email=$(curl -fsS https://reflection.int.exe.xyz/email | jq -r .email)
git add .gitignore .python-version README.md manage.py mysite pyproject.toml templates uv.lock
git -c user.name="$email" -c user.email="$email" commit --quiet -m "New Django project"
git log --oneline
Production settings#
The VM's settings live outside your repository. ~/app.env holds the
secrets and per-server values; systemd and the deploy hook both read it,
so keep to KEY=value lines without quotes or spaces. This finds your
project's settings module and WSGI application, then writes the file
once, with a new secret key:
cd ~/seed
settings=$(sed -n "s/.*DJANGO_SETTINGS_MODULE['\"], *['\"]\([^'\"]*\)['\"].*/\1/p" manage.py | head -n 1)
wsgi=$(grep -rhoP --include='*.py' "^WSGI_APPLICATION\s*=\s*['\"]\K[^'\"]+" . | head -n 1)
wsgi="${wsgi%.*}:${wsgi##*.}"
echo "settings: $settings wsgi: $wsgi"
if [ ! -e ~/app.env ]; then
install -m 600 /dev/null ~/app.env
cat >> ~/app.env <<EOF
# The Django app's settings on this VM, read by app.service and the
# deploy hook. One KEY=value per line: no quotes, no spaces.
DJANGO_SETTINGS_MODULE=exe_settings
PYTHONPATH=/home/exedev/app-settings
PROJECT_SETTINGS=$settings
WSGI_APP=$wsgi
DJANGO_SECRET_KEY=$(openssl rand -hex 32)
DJANGO_ALLOWED_HOSTS=$(hostname).exe.xyz,localhost
DJANGO_ADMINS=$(curl -fsS https://reflection.int.exe.xyz/email | jq -r .email)
WEB_CONCURRENCY=3
EOF
fi
grep -v SECRET ~/app.env
Check the output shows your settings module (like mysite.settings) and
WSGI application (like mysite.wsgi:application); fix the file if not.
If your project has a separate production settings module, use that for
PROJECT_SETTINGS. DJANGO_ADMINS (your exe.dev email) gets Django's
error reports, and WEB_CONCURRENCY is gunicorn's number of worker
processes.
The settings module itself. It imports your project's settings, then overrides what the server decides:
cat > ~/app-settings/exe_settings.py <<'EOF'
"""Production settings for this exe.dev VM.
Loads the project's settings (PROJECT_SETTINGS in ~/app.env), then sets
what the server decides. The repository doesn't change, and runserver on
your computer keeps using the project's own settings.
"""
import os
from importlib import import_module
from pathlib import Path
import django
_project = import_module(os.environ["PROJECT_SETTINGS"])
globals().update({k: v for k, v in vars(_project).items() if k.isupper()})
DATA = Path.home() / "data"
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
# exe.dev's proxy ends TLS and forwards the request with the original Host
# and X-Forwarded-Proto: https. Without SECURE_PROXY_SSL_HEADER, Django
# thinks every request is plain HTTP: forms fail with "CSRF verification
# failed. Origin checking failed", and absolute URLs start with http://.
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# exe.dev already redirects http:// to https:// and sends HSTS, so
# SECURE_SSL_REDIRECT and SECURE_HSTS_SECONDS would only repeat it.
SILENCED_SYSTEM_CHECKS = [
*globals().get("SILENCED_SYSTEM_CHECKS", []),
"security.W004",
"security.W008",
]
# SQLite on the persistent disk, set up for a web server.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA / "db.sqlite3",
"CONN_MAX_AGE": 600,
"OPTIONS": {
# Take the write lock when a transaction starts, so concurrent
# writers wait their turn instead of failing with "database is
# locked"; wait up to 5 seconds for it.
"transaction_mode": "IMMEDIATE",
"timeout": 5,
"init_command": (
"PRAGMA journal_mode=WAL;" # readers don't block the writer
"PRAGMA synchronous=NORMAL;" # safe with WAL, and faster
"PRAGMA mmap_size=134217728;" # read through 128 MB of mmap
"PRAGMA journal_size_limit=27103364;" # trim the WAL file
"PRAGMA cache_size=2000;"
),
},
}
}
# nginx serves both from disk. STATIC_ROOT is inside the release being
# built or run: the deploy hook and app.service run there.
STATIC_URL = "/static/"
STATIC_ROOT = Path.cwd() / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = DATA / "media"
# A STORAGES setting that only names the static files storage leaves
# uploads without one; store them on disk, in MEDIA_ROOT.
if "STORAGES" in globals() and "default" not in STORAGES:
STORAGES = {
**STORAGES,
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
}
# With DEBUG off, Django's default logging prints nothing; send warnings
# and errors, with tracebacks, to the journal: journalctl -u app.
if "LOGGING" not in globals():
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "WARNING"},
}
# Email goes through exe.dev's gateway (exe_mail.py), unless the project
# already sends real email: Django's development backends only print it.
# Django emails ADMINS a traceback when a page fails with a server error.
_dev_mail = {
f"django.core.mail.backends.{name}.EmailBackend"
for name in ("console", "dummy", "filebased", "locmem")
}
if "MAILERS" in globals(): # Django 6.1 and later
if MAILERS.get("default", {}).get("BACKEND") in _dev_mail:
MAILERS = {**MAILERS, "default": {"BACKEND": "exe_mail.EmailBackend"}}
elif globals().get("EMAIL_BACKEND") in _dev_mail or not {"EMAIL_BACKEND", "EMAIL_HOST"} & globals().keys():
EMAIL_BACKEND = "exe_mail.EmailBackend"
if not globals().get("ADMINS") and os.environ.get("DJANGO_ADMINS"):
_admins = os.environ["DJANGO_ADMINS"].split(",")
# Django 6.0 takes addresses; older versions take (name, address).
ADMINS = _admins if django.VERSION >= (6, 0) else [(a, a) for a in _admins]
# Sign visitors in with their exe.dev account; see exe_login.py.
_auth = "django.contrib.auth.middleware.AuthenticationMiddleware"
if _auth in MIDDLEWARE:
MIDDLEWARE = list(MIDDLEWARE)
MIDDLEWARE.insert(MIDDLEWARE.index(_auth) + 1, "exe_login.ExeDevLoginMiddleware")
AUTHENTICATION_BACKENDS = [
"exe_login.ExeDevBackend",
*globals().get("AUTHENTICATION_BACKENDS", ["django.contrib.auth.backends.ModelBackend"]),
]
EOF
The exe.dev login, using Django's own RemoteUserMiddleware with
exe.dev's header:
cat > ~/app-settings/exe_login.py <<'EOF'
"""Sign visitors in with their exe.dev account.
exe.dev's proxy adds X-ExeDev-Email to requests from visitors who have
signed in to exe.dev on this site (https://exe.dev/docs/login-with-exe;
public sites: /__exe.dev/login?redirect=/admin/). This signs
them in as the Django user whose username is that email address, and
signs them out when the header goes away.
"""
from django.contrib.auth.backends import RemoteUserBackend
from django.contrib.auth.middleware import RemoteUserMiddleware
class ExeDevLoginMiddleware(RemoteUserMiddleware):
header = "HTTP_X_EXEDEV_EMAIL"
class ExeDevBackend(RemoteUserBackend):
# False: only people who already have a user are signed in.
# True: everyone who can open the site gets a user, with no
# permissions until you grant some in the admin.
create_unknown_user = False
def clean_username(self, username):
return username.strip().lower()
def configure_user(self, request, user, created=True):
if created and hasattr(user, "email"):
user.email = user.get_username()
user.save(update_fields=["email"])
return user
EOF
And email, through the VM's email gateway:
cat > ~/app-settings/exe_mail.py <<'EOF'
"""Send Django's email through exe.dev's email gateway.
Plain text and attachments, to the VM's owner, its team, and people it's
shared with who have signed in (https://exe.dev/docs/send-email); the
gateway refuses other addresses. To email anyone, configure an email
provider in your project's settings; exe_settings.py then leaves it be.
"""
import base64
import json
import urllib.error
import urllib.request
from django.core.mail.backends.base import BaseEmailBackend
GATEWAY = "http://169.254.169.254/gateway/email/send"
class EmailBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(**kwargs)
self.fail_silently = fail_silently
def send_messages(self, email_messages):
sent = 0
for message in email_messages:
try:
for to in message.recipients():
self.send(message, to)
sent += 1
except Exception:
if not self.fail_silently:
raise
return sent
def send(self, message, to):
data = {"to": to, "subject": message.subject, "body": message.body}
if message.reply_to:
data["reply_to"] = message.reply_to[0]
files = [a for a in message.attachments if isinstance(a, tuple)]
if files:
data["attachments"] = [
{
"filename": name,
"content": base64.b64encode(
content.encode() if isinstance(content, str) else content
).decode(),
"content_type": mimetype or "application/octet-stream",
}
for name, content, mimetype in files
]
request = urllib.request.Request(
GATEWAY, json.dumps(data).encode(), {"Content-Type": "application/json"}
)
try:
urllib.request.urlopen(request, timeout=10).close()
except urllib.error.HTTPError as e: # e.g. 403 for other addresses
error = e.read().decode(errors="replace")
raise OSError(f"exe.dev email to {to}: {error}") from e
EOF
The deploy hook#
~/app.git is the repository you push to. Its pre-receive hook builds
each push to main as a new release before git accepts it: it installs
dependencies with uv, runs check --deploy, migrate, and
collectstatic, loads the app, then points ~/app at the release,
restarts, and checks that the site answers. If any step fails, git
rejects the push and the previous release keeps running (or is put back,
if the new one fails after the switch).
git init --quiet --bare ~/app.git
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# Deploy pushes to main: build a new release in ~/releases, migrate,
# collect static files, switch ~/app to it, restart, and check it
# answers. Any failure rejects the push; the running release stays.
set -euo pipefail
umask 022
exec 9>"$HOME/.deploy.lock" && flock 9
new=
while read -r _old sha ref; do
if [ "$ref" = refs/heads/main ]; then new=$sha; fi
done
if [ -z "$new" ]; then echo "Only pushes to main deploy."; exit 0; fi
if [ "$new" = 0000000000000000000000000000000000000000 ]; then
echo "Refusing to delete main."; exit 1
fi
rel="$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${new:0:7}"
prev=$(readlink "$HOME/app" || true)
# Remove the new release unless it ended up live.
trap '[ "$(readlink -f "$HOME/app")" = "$rel" ] || rm -rf "$rel"' EXIT
echo "-----> Building ${new:0:7}"
mkdir -p "$rel"
git archive "$new" | tar -x -C "$rel"
# Leave git's push environment behind before running the build.
unset GIT_DIR GIT_QUARANTINE_PATH GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
set -a; . "$HOME/app.env"; set +a
cd "$rel"
if [ -f uv.lock ]; then
uv sync --locked --no-dev
elif [ -f requirements.txt ]; then
py=
if [ ! -f .python-version ] && [ -f runtime.txt ]; then
# Heroku's runtime.txt: python-3.12.4 means Python 3.12.
py=$(sed -n 's/^python-\([0-9]*\.[0-9]*\).*/\1/p' runtime.txt)
fi
uv venv --quiet ${py:+--python "$py"}
uv pip install -r requirements.txt
else
uv sync --no-dev
fi
uv pip install --quiet gunicorn # does nothing if the project has it
echo "-----> Checking, migrating, collecting static files"
.venv/bin/python manage.py check --deploy
.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py collectstatic --noinput --verbosity 0
# Load the app the way gunicorn will, so startup errors stop here.
.venv/bin/python -c 'import os, gunicorn.util; gunicorn.util.import_app(os.environ["WSGI_APP"])'
switch() { ln -sfn "$1" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"; }
restart() {
sudo systemctl restart app
if systemctl is-enabled --quiet app-worker 2>/dev/null; then sudo systemctl restart app-worker; fi
}
echo "-----> Starting ${new:0:7}"
switch "$rel"
restart
for _ in $(seq 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/ || true)
case "$code" in [1234]??|500) break ;; esac
# Stop waiting once gunicorn has crashed and systemd restarted it.
[ "$(systemctl show app -p NRestarts --value)" = 0 ] || break
sleep 1
done
case "$code" in
[1234]??) ;;
*)
echo "-----> The new release answered HTTP $code. Logs:"
journalctl -u app -n 40 -o cat --no-pager || true
if [ -n "$prev" ]; then echo "-----> Going back to $prev"; switch "$prev"; restart; fi
exit 1 ;;
esac
# Keep the five newest releases.
ls -1d "$HOME"/releases/*/ | sort | head -n -5 | xargs -r rm -rf
echo "-----> Deployed ${new:0:7}: https://$(hostname).exe.xyz/"
EOF
chmod +x ~/app.git/hooks/pre-receive
manage runs manage.py in the live release with the server's
settings, from any directory:
cat > ~/.local/bin/manage <<'EOF'
#!/bin/sh
# manage <command>: Django's manage.py for the live app, with its settings.
set -a; . "$HOME/app.env"; set +a
cd "$HOME/app" && exec .venv/bin/python manage.py "$@"
EOF
chmod +x ~/.local/bin/manage
Run it with systemd and nginx#
app.service runs gunicorn on 127.0.0.1:8001, from whichever release
~/app points at, and restarts it if it stops:
sudo tee /etc/systemd/system/app.service >/dev/null <<'EOF'
[Unit]
Description=Django app (gunicorn)
After=network.target
[Service]
User=exedev
Group=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
ExecStart=/home/exedev/app/.venv/bin/gunicorn --bind 127.0.0.1:8001 --access-logfile - ${WSGI_APP}
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app
nginx listens on port 8000, where exe.dev's HTTPS proxy
sends traffic. It serves /static/ and /media/ from disk and passes
everything else to gunicorn, along with exe.dev's headers. It replaces
nginx's default site, which also uses port 8000.
sudo tee /etc/nginx/sites-available/app >/dev/null <<'EOF'
# exe.dev's proxy -> nginx :8000 -> gunicorn 127.0.0.1:8001.
# Files with a content hash in their name (ManifestStaticFilesStorage)
# never change, so browsers may cache them forever.
map $uri $static_expires {
"~\.[0-9a-f]{12}\.\w+$" max;
default off;
}
server {
listen 8000 default_server;
listen [::]:8000 default_server;
server_name _;
client_max_body_size 100m;
gzip_types text/css application/javascript application/json image/svg+xml;
location /static/ {
alias /home/exedev/app/staticfiles/;
expires $static_expires;
access_log off;
}
location /media/ {
alias /home/exedev/data/media/;
add_header X-Content-Type-Options nosniff;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_redirect off;
}
}
EOF
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo nginx -t
sudo systemctl enable --now nginx
sudo systemctl reload nginx
Expect syntax is ok and test is successful. Until the first deploy,
curl localhost:8000 returns 502: gunicorn isn't running yet.
Deploy#
Push the checkout to ~/app.git. This is the same deploy a git push
from your computer runs; the first one also downloads Python if needed:
cd ~/seed && git push ~/app.git HEAD:main
It ends with -----> Deployed. Check the site, a static file, and the
admin login page:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/static/admin/css/base.css
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/admin/login/
manage check --deploy
sqlite3 ~/data/db.sqlite3 'PRAGMA journal_mode'
Expect 200 for each (your own app's home page may redirect with 301
or 302, or answer 404 if it has none), System check identified no issues, and wal.
The checkout has done its job:
cd ~ && rm -rf ~/seed
Create your admin user#
Make the VM owner's exe.dev account a superuser. It has no password; exe.dev's login signs you in:
email=$(curl -fsS https://reflection.int.exe.xyz/email | jq -r .email)
manage createsuperuser --noinput --username "$email" --email "$email"
curl -s -o /dev/null -w '%{http_code}\n' -H "X-ExeDev-Email: $email" http://localhost:8000/admin/
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/admin/
Expect 200 with the header (signed in) and 302 without it (sent to
the login page). If your project's user model logs in by email rather
than username, use --email "$email" alone.
Check uploads, too: save a file through Django's storage and fetch it from nginx:
manage shell -v 0 -c 'from django.core.files.base import ContentFile; from django.core.files.storage import default_storage as s; print(s.save("hello.txt", ContentFile(b"hello")))'
curl -s http://localhost:8000/media/hello.txt; echo
rm ~/data/media/hello.txt
Expect hello.txt, then hello.
Set up nightly backups#
app-backup takes a consistent copy of the database with SQLite's
backup command (copying a live database file with cp can catch it
half-written), checks it, and packs it with the uploads, ~/app.env, the
settings, and a bundle of the git repository. It keeps seven.
cat > ~/.local/bin/app-backup <<'EOF'
#!/usr/bin/env bash
# Back up the Django app to ~/backups: the database, uploads, settings
# and secrets, and the git repository. Keeps the 7 newest.
set -euo pipefail
umask 077
mkdir -p "$HOME/backups"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
sqlite3 "$HOME/data/db.sqlite3" ".backup '$work/db.sqlite3'"
test "$(sqlite3 "$work/db.sqlite3" 'PRAGMA integrity_check')" = ok
git -C "$HOME/app.git" bundle create --quiet "$work/app.bundle" --all
out="$HOME/backups/app-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" --exclude=__pycache__ \
-C "$work" db.sqlite3 app.bundle \
-C "$HOME" app.env app-settings \
-C "$HOME/data" media
mv "$out.part" "$out"
ls -1 "$HOME"/backups/app-*.tar.gz | sort | head -n -7 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/.local/bin/app-backup
Run it every night with a systemd timer, and take the first backup now:
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the Django app to ~/backups
[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/.local/bin/app-backup
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the Django app nightly
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
sudo systemctl start app-backup.service
systemctl list-timers app-backup.timer
ls -lh ~/backups
Check the newest backup in a scratch directory: the database passes an integrity check, and the repository bundle has your commit.
latest=$(ls -1 ~/backups/app-*.tar.gz | sort | tail -n 1)
rm -rf /tmp/restore-check && mkdir /tmp/restore-check
tar -xzf "$latest" -C /tmp/restore-check
sqlite3 /tmp/restore-check/db.sqlite3 'PRAGMA integrity_check; SELECT count(*) FROM django_migrations;'
git -C /tmp/restore-check bundle list-heads app.bundle
rm -rf /tmp/restore-check
Expect ok, a number of migrations, and refs/heads/main. Setup is
done: everything is enabled to start at boot. Continue with
Open your app.
Back up and restore
Take a backup any time, on the VM: app-backup. It prints the archive's
path. Copy archives off the VM as in Your data.
Restore on the same VM#
Use your archive's name. This stops the app, keeps a copy of the current
database in ~/data/db.before-restore.sqlite3, restores the database and
uploads, and starts the app again:
backup=~/backups/app-20261017-030000.tar.gz
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
sudo systemctl stop app
sqlite3 ~/data/db.sqlite3 ".backup '$HOME/data/db.before-restore.sqlite3'"
chmod 600 ~/data/db.before-restore.sqlite3
sqlite3 ~/data/db.sqlite3 ".restore '/tmp/restore/db.sqlite3'"
rsync -a --delete /tmp/restore/media/ ~/data/media/
sudo systemctl start app
rm -rf /tmp/restore
If you run app-worker, stop and start it with app. The code doesn't
change; to go back to an older release too, push it (git push -f exe <commit>:main).
Restore on a new VM#
Create a VM and copy the archive to it. On your computer:
ssh exe.dev new --name=my-django-2
scp app-20261017-030000.tar.gz my-django-2.exe.xyz:
On the new VM, unpack it and put the settings, data and repository in place, with the new VM's name in the allowed hosts:
mkdir /tmp/restore && tar -xzf ~/app-20261017-030000.tar.gz -C /tmp/restore
chmod 711 ~
mkdir -p ~/releases ~/.local/bin
install -d -m 711 ~/data
cp -a /tmp/restore/app.env /tmp/restore/app-settings ~/
sed -i "s/^DJANGO_ALLOWED_HOSTS=[^,]*/DJANGO_ALLOWED_HOSTS=$(hostname).exe.xyz/" ~/app.env
install -m 600 /tmp/restore/db.sqlite3 ~/data/db.sqlite3
cp -a /tmp/restore/media ~/data/media && chmod 755 ~/data/media
git clone --quiet /tmp/restore/app.bundle ~/seed
Then follow Set it up by hand from The deploy hook
to the end, skipping Create your admin user: your users came with the
database. Its Deploy step pushes ~/seed and starts the app. If you
ran a worker or other timers, set them up again as in
Background tasks. Add the
new name as a remote on your computer (git remote set-url exe my-django-2.exe.xyz:app.git). For a copy of the whole VM instead, run
ssh exe.dev cp my-django my-django-2 on your computer.
Update Django, Python, and uv
Django and your other dependencies are pinned in your project's lock file, so updates are commits. In your project, on your computer:
uv lock --upgrade-package django
uv run manage.py check
git commit -am "Update Django" && git push exe main
The ~=6.1.0 pin in a new project takes 6.1.x security releases. For
the next feature release, read its
release notes, change
the pin, and fix any deprecation warnings first.
Python follows .python-version (or requires-python). Change it, run
uv lock, commit, and push; uv installs the new version on the VM during
the deploy. For the latest patch release of your current version, on the
VM: uv python upgrade, then redeploy by pushing a commit.
uv itself is in /usr/local/bin. To update it, on the VM:
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_UNMANAGED_INSTALL=/usr/local/bin sh
uv --version
nginx and the rest of Ubuntu update with sudo apt update && sudo apt upgrade.
Something not working?
On the VM, start with the logs:
journalctl -u app -n 50 --no-pager
sudo tail -n 20 /var/log/nginx/error.log
ls -l ~/app
- "CSRF verification failed. Origin checking failed": Django doesn't
know the request came over HTTPS, or the host isn't allowed. Check that
SECURE_PROXY_SSL_HEADERis set (it is inexe_settings.py) and that the hostname in your address bar is inDJANGO_ALLOWED_HOSTS. - "Too many redirects": your project's settings set
SECURE_SSL_REDIRECT = Trueand something droppedX-Forwarded-Proto; check nginx passes it, as above. - 400 Bad Request: the hostname isn't in
DJANGO_ALLOWED_HOSTSin~/app.env. Add it andsudo systemctl restart app. - 502 Bad Gateway: gunicorn isn't running.
journalctl -u appshows why, usually an import error or a missing variable in~/app.env. - 500 errors: the traceback is in
journalctl -u app. - A push is rejected: read the
remote:lines; they're the build's output. The running release andmainhaven't changed. A failed build that needs a system library (mysqlclient,psycopg2without-binary) wantssudo apt installof its-devpackage on the VM. - 403 on static files or uploads: nginx can't read them. Check
ls -ld ~ ~/data ~/data/mediashowsdrwx--x--xfor the first two. - "database is locked": a long transaction is holding the write lock for more than 5 seconds. Keep transactions short, or move slow work to a background task.
- 413 Request Entity Too Large: raise
client_max_body_sizein/etc/nginx/sites-available/app, thensudo systemctl reload nginx. - Not signed in to the admin: the user's username must be your exe.dev email address, in lowercase, and the user must be active and staff.
Frequently asked questions#
Is SQLite good enough for a production Django site?
For most sites on one server, yes. In WAL mode readers never wait for the writer, and with transaction_mode set to IMMEDIATE writers queue for up to five seconds instead of failing with "database is locked". That handles thousands of page views a minute. Move to PostgreSQL when you need several servers or long write-heavy transactions.
Can I use PostgreSQL instead?
Yes. Set up PostgreSQL on the same VM with the PostgreSQL recipe and put its DATABASE_URL in ~/app.env. Add dj-database-url and psycopg[binary] to your project, and in ~/app-settings/exe_settings.py replace the DATABASES block with import dj_database_url and DATABASES = {"default": dj_database_url.config(conn_max_age=600)}. Move existing data with manage dumpdata and manage loaddata. app-backup only copies SQLite; the PostgreSQL recipe has its own nightly backups.
How do I run manage.py commands on the server?
On the VM, manage <command> runs manage.py in the live release with its production settings: manage shell, manage createsuperuser, manage showmigrations. Or ask Shelley.
Do I need WhiteNoise?
No. nginx serves /static/ and /media/ straight from disk. If your project already uses WhiteNoise, leave it in: it does no harm, and it keeps working when you run the app elsewhere.
Can I run Celery, Channels, or an ASGI app?
Yes, it's your VM. For background jobs, Django's own Tasks framework with a database queue (below) needs no Redis. For Celery, install Redis with sudo apt install redis-server and add an app-worker.service for the worker. For ASGI, change the gunicorn line in app.service to --worker-class asgi and point it at your asgi module.
What does it cost?
Django is free and open source. You pay for the VM; see exe.dev pricing. One VM can run several small apps, but one app per VM keeps deploys and backups simple.
Updated Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us