---
title: "Deploy a Django app"
description: "Deploy Django to a VM of its own: git push to deploy, gunicorn and nginx, SQLite and uploads on a persistent disk, and the admin behind exe.dev login."
url: https://exe.dev/deploy/django
category: "Frameworks"
updated: 2026-10-17
---

# Deploy a Django app

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

- **Software:** Django (https://www.djangoproject.com)
- **Source:** https://github.com/django/django
- **License:** BSD-3-Clause
- **Time:** 10 minutes
- **Example VM name:** `my-django` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/django/manifest.json

## 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.

Running on the VM:

- **Your Django app**: Django 6.1 on Python 3.14, installed by uv; one release per deploy, data in `~/app`
- **gunicorn**: app.service, the WSGI server, port 8001
- **nginx**: static files, uploads, and a buffer in front of gunicorn, port 8000
- **SQLite**: WAL mode, tuned for a web server, data in `~/data/db.sqlite3`
- **Uploads**: MEDIA_ROOT, served at /media/, data in `~/data/media`
- **git push deploys**: a bare repository with a deploy hook, data in `~/app.git`
- **Nightly backups**: database, uploads, settings, and code; keeps a week, data in `~/backups`

How it is reached:

- https://my-django.exe.xyz/ (private): your app, until you make it public
- https://my-django.exe.xyz/admin/ (private): the Django admin, signed in with your exe.dev account
- my-django.exe.xyz:app.git (private): where you `git push` to deploy

## Deploy with Shelley

Open https://exe.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Django%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fdjango.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Django%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-django.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-django.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20Django%20app%2C%20or%20a%20new%20Django%206.1%20project%2C%20served%20by%20gunicorn%20behind%20nginx%20on%20port%208000%2C%20under%20systemd%20and%20enabled%20to%20start%20after%20a%20reboot.%0A-%20%60git%20push%20exe%20main%60%20deploys%3A%20the%20pre-receive%20hook%20in%20%60~%2Fapp.git%60%20builds%20and%20migrates%20each%20push%20in%20a%20new%20%60~%2Freleases%60%20directory%20and%20switches%20%60~%2Fapp%60%20to%20it%20only%20if%20every%20step%20passes%2C%20so%20a%20failing%20build%20rejects%20the%20push%20and%20the%20running%20release%20stays.%0A-%20Production%20settings%20for%20exe.dev%27s%20proxy%20in%20%60~%2Fapp-settings%60%20and%20%60~%2Fapp.env%60%2C%20with%20DEBUG%20off%2C%20a%20generated%20secret%20key%2C%20HTTPS%20detected%2C%20and%20allowed%20hosts%20and%20CSRF%20origins%20set%3B%20%60manage%20check%20--deploy%60%20reports%20no%20issues.%0A-%20SQLite%20in%20WAL%20mode%20at%20%60~%2Fdata%2Fdb.sqlite3%60%2C%20and%20uploads%20in%20%60~%2Fdata%2Fmedia%60%20served%20at%20%60%2Fmedia%2F%60.%0A-%20The%20owner%27s%20exe.dev%20account%20is%20a%20Django%20superuser%20and%20opens%20%60%2Fadmin%2F%60%20without%20a%20password.%0A-%20Nightly%20backups%20in%20%60~%2Fbackups%60%2C%20with%20a%20first%20one%20taken%20and%20checked.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20follow%20%22Set%20it%0Aup%20by%20hand%22%20in%20order%2C%20skipping%20%22Create%20the%20VM%22%3B%20the%20walkthrough%20above%20it%0Ais%20for%20the%20owner.%20REPO%3Dnew%20takes%20%22Start%20a%20new%20project%22%3B%20anything%20else%0Atakes%20%22Bring%20your%20repository%22.%20If%20cloning%20from%20GitHub%20fails%2C%20the%0Arepository%20is%20private%3A%20give%20the%20owner%20the%20%60ssh%20exe.dev%20integrations%20add%0Agithub%60%20command%20from%20that%20step%2C%20with%20this%20VM%27s%20name%20and%20the%20repository%2C%0Await%20for%20them%2C%20and%20clone%20from%20github.int.exe.xyz.%0ANever%20overwrite%20an%20existing%20~%2Fapp.env.%20Don%27t%20commit%20to%20the%20owner%27s%20code%2C%0Aand%20don%27t%20push%20test%20commits%20to%20~%2Fapp.git%3A%20read%20the%20hook%20to%20check%20it.%0AIf%20their%20repository%20doesn%27t%20fit%20the%20deploy%20hook%20%28manage.py%20not%20at%20the%0Atop%2C%20Pipenv%2C%20a%20custom%20user%20model%20without%20a%20username%20field%29%2C%20adapt%20the%0Ahook%20or%20the%20command%20and%20tell%20the%20owner%20what%20you%20changed.%0ALeave%20the%20VM%20private%20and%20don%27t%20reboot%3A%20check%20that%20app.service%2C%20nginx%20and%0Aapp-backup.timer%20are%20enabled%20instead.%20Skip%20the%20later%20Restore%20and%20Update%0Ainstructions%3B%20the%20restore%20check%20in%20setup%20uses%20a%20scratch%20copy%20only.%0AThe%20URLs%20to%20give%20are%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%20and%20%2Fadmin%2F.%20Of%20the%20two%0Acommands%20at%20the%20end%2C%20give%20the%20owner%20only%20the%20one%20that%20fits%20REPO%2C%0Afollowed%20by%20%60git%20push%20exe%20main%60%2C%20and%20say%20what%20you%20adapted%2C%20if%20anything.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20git%20clone%20-o%20exe%20my-django.exe.xyz%3Aapp.git%20my-django%20%20%23%20if%20you%20started%20a%20new%20project%0A%20%20git%20remote%20add%20exe%20my-django.exe.xyz%3Aapp.git%20%20%20%20%20%20%20%20%20%20%23%20or%2C%20in%20your%20own%20repository%27s%20checkout%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/django/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
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
```

Or follow the steps below by hand.

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](https://gunicorn.org) runs Django
behind nginx, which serves static files and uploads from disk and buffers
slow clients, as gunicorn's docs recommend. [uv](https://docs.astral.sh/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.

## 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](#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`:

```bash
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:

```bash
git remote add exe my-django.exe.xyz:app.git
```

Commit a change and push it:

```bash
git push exe main
```

The deploy streams back into your terminal: dependencies, checks,
migrations, static files, then the restart.

```text
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](/docs/send-email):
  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:

```bash
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](#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](/docs/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:

```bash
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:

```bash
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](/docs/cnames). On your computer:

```bash
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.

```bash
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](https://docs.djangoproject.com/en/6.1/topics/tasks/)
built in: decorate a function with `@task` and call `.enqueue()`. Django
only ships backends for tests, so add
[django-tasks-db](https://github.com/RealOrangeOne/django-tasks-db), which
keeps the queue in your database. No Redis needed.

In your project, on your computer:

```bash
uv add django-tasks-db
```

In `settings.py`, add `"django_tasks_db"` to `INSTALLED_APPS` and:

```python
TASKS = {"default": {"BACKEND": "django_tasks_db.DatabaseBackend"}}
```

A task, in any app's `tasks.py`:

```python
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.

```bash
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:

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Set it up by hand</summary>

### Create the VM

On your computer (pick another name if `my-django` is taken):

```bash
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`.

```bash
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:

```bash
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](/docs/integrations-github) instead, so no token
lands on the VM. On your computer:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](/docs/send-email):

```bash
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).

```bash
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:

```bash
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:

```bash
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](/docs/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.

```bash
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:

```bash
cd ~/seed && git push ~/app.git HEAD:main
```

It ends with `-----> Deployed`. Check the site, a static file, and the
admin login page:

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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:

```bash
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.

```bash
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](#open-your-app).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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](#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:

```bash
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:

```bash
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:

```bash
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](#create-the-vm) 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](#background-tasks-and-scheduled-jobs). 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.

</details>

<details id="update" class="dp-details">
<summary>Update Django, Python, and uv</summary>

Django and your other dependencies are pinned in your project's lock
file, so updates are commits. In your project, on your computer:

```bash
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](https://docs.djangoproject.com/en/6.1/releases/), 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:

```bash
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`.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM, start with the logs:

```bash
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.

</details>

## 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](/deploy/postgres#run-it-next-to-your-app) 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](https://exe.dev/pricing). One VM can run several small apps, but one app per VM keeps deploys and backups simple.


## Related

- [Deploy a FastAPI app](https://exe.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Migrate a Heroku app to exe.dev](https://exe.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Deploy a Docker Compose app from GitHub](https://exe.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Deploy a Laravel app](https://exe.dev/deploy/laravel.md): Your Laravel app on a server of its own. git push to deploy.
