Skip to content

Deploy a Django app

Your Django app on a computer of its own. git push to deploy.

Deploy on exe.dev

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/stdin

Your 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 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.
What's running, and where
Private https://my-django.exe.xyz/ your app, until you make it public
Private https://my-django.exe.xyz/admin/ the Django admin, signed in with your exe.dev account
Private my-django.exe.xyz:app.git where you git push to deploy
my-django.exe.xyz exe.dev VM
Your Django app Django 6.1 on Python 3.14, installed by uv; one release per deploy ~/app
gunicorn app.service, the WSGI server :8001
nginx static files, uploads, and a buffer in front of gunicorn :8000
SQLite WAL mode, tuned for a web server ~/data/db.sqlite3
Uploads MEDIA_ROOT, served at /media/ ~/data/media
git push deploys a bare repository with a deploy hook ~/app.git
Nightly backups database, uploads, settings, and code; keeps a week ~/backups

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, or new to start a fresh Django app
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/django/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin

When 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 checkout
What 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 exe is where you deploy, like heroku used 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, say STRIPE_KEY=..., add a line there and read it with os.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_HEADER is set (it is in exe_settings.py) and that the hostname in your address bar is in DJANGO_ALLOWED_HOSTS.
  • "Too many redirects": your project's settings set SECURE_SSL_REDIRECT = True and something dropped X-Forwarded-Proto; check nginx passes it, as above.
  • 400 Bad Request: the hostname isn't in DJANGO_ALLOWED_HOSTS in ~/app.env. Add it and sudo systemctl restart app.
  • 502 Bad Gateway: gunicorn isn't running. journalctl -u app shows 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 and main haven't changed. A failed build that needs a system library (mysqlclient, psycopg2 without -binary) wants sudo apt install of its -dev package on the VM.
  • 403 on static files or uploads: nginx can't read them. Check ls -ld ~ ~/data ~/data/media shows drwx--x--x for 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_size in /etc/nginx/sites-available/app, then sudo 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