This guide assumes you have a Django project that already runs on your laptop and that you have followed Quickstart to add a server. It focuses on what changes between local development and a Code</>sensei deploy.
Project layout assumptions
The deploy script does not require any particular project layout, but it does assume:
requirements.txtat the repository root (or wherever you pointed the requirements file path).- A
manage.pyat the same level asrequirements.txt. - A Django settings module addressable as a dotted Python path —
e.g.
config.settings.production. - A
STATIC_ROOTdefined in the settings module socollectstatichas somewhere to write.
If your project uses a build tool other than pip (Poetry, uv, pdm),
keep a requirements.txt generated from your lockfile in
the repository. The deploy script does not currently run
poetry install or uv sync directly — it
calls pip install -r <requirements file>.
The environment variable contract
Every environment variable defined on the project's Environment
vars field is written to shared/.env on the server,
one KEY=value per line. The deploy script sources that
file before running manage.py and includes
EnvironmentFile=/srv/codesensei/<slug>/shared/.env
in the systemd unit, so both build-time and run-time see the same
values.
If a value contains <, >,
(, ), |, ;, or a
literal space, quote it with single quotes in the JSON you paste into
the form:
{"FOO": "'bar < baz'"}
This avoids the silent set -a; . .env; set +a abort
that would otherwise drop every export below the unquoted character.
The form stores the value verbatim; the quoting is what gets sourced
on the server.
Migration order
The deploy script runs manage.py migrate --noinput
before flipping the current symlink to the new
release. This is the right order for additive migrations (new tables,
new nullable columns) — the old release continues to serve requests
against the migrated schema while the new release is being prepared
to take over.
For destructive migrations (dropping a column, dropping a table, narrowing a constraint), the old release will start hitting errors as soon as the migration runs but before the symlink flip. The right pattern is a two-step deploy: first deploy a release that no longer reads the destructive-target column (no migration in this release); then deploy a second release that contains the destructive migration. This is the same pattern any blue/green deploy of a Django app needs and is not specific to Code</>sensei.
Static assets
The deploy script runs manage.py collectstatic
--noinput after migrations. Your STATIC_ROOT should
point at a path inside the release directory — most projects use
BASE_DIR / "collected-static". The edge proxy is
configured to serve /static/ directly from the
release's STATIC_ROOT with a one-year
Cache-Control, so use
ManifestStaticFilesStorage (or WhiteNoise's compressed
manifest backend) to hash your filenames if you want safe browser
caching.
The edge proxy does not serve MEDIA_ROOT by
default. If you allow user uploads, point MEDIA_ROOT at
shared/media/ on the server so uploads survive across
deploys, and decide whether you want the proxy to serve that path
directly (fast, no Django round-trip) or whether you want Django to
handle it (slower, but lets you check authorization).
The gunicorn invocation
The deploy script writes a systemd unit roughly like this:
[Unit]
Description=<project-name> (codesensei)
After=network-online.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/srv/codesensei/<slug>/current
EnvironmentFile=/srv/codesensei/<slug>/shared/.env
ExecStart=/srv/codesensei/<slug>/shared/venv/bin/gunicorn \
--workers 3 \
--bind unix:/srv/codesensei/<slug>/shared/gunicorn.sock \
--access-logfile /srv/codesensei/<slug>/shared/logs/access.log \
--error-logfile /srv/codesensei/<slug>/shared/logs/error.log \
config.wsgi:application
Restart=always
[Install]
WantedBy=multi-user.target
The number of workers is currently fixed at three. If you need to
tune it (very small VMs benefit from one worker, busy boxes from
2 * cores + 1), set the GUNICORN_WORKERS
environment variable on the project; the deploy script reads it when
present and falls back to three when not.
Daphne is used in place of gunicorn for projects that declare an
ASGI_APPLICATION in their settings, so projects using
Django Channels for WebSockets do not need any extra configuration.
Common Django-side issues during first deploy
- "DisallowedHost: Invalid HTTP_HOST header" — set
DJANGO_ALLOWED_HOSTSin the project's Environment vars. For the rendered<uuid>.apps.codesensei.cloudhostname you also need.apps.codesensei.cloudin the list, orALLOWED_HOSTS = ["*"]if you are happy with that risk (usually fine for a sandbox; not for a production app behind a trusted proxy). - "Operational error: connection to server failed"
— your
DATABASE_URLpoints at127.0.0.1but Postgres is not yet installed on the server. Either install Postgres yourself before the first deploy, or point at a managed database provider. - "You are trying to add a non-nullable field … without a default" — your migration was generated against a different state of the DB than the one on the server. Generate the migration again locally against a freshly-migrated DB, commit, and redeploy.
- "500 Server Error" on the deployed URL, but the deploy
reports success — the application failed to boot after
gunicorn started. Use
journalctl -u <slug>.service --since "5 minutes ago"on the server to read the gunicorn error log. Most commonly:DEBUG = Falsewith noSECRET_KEYset, orSTATIC_URLmissing.
Celery, background workers, and beat schedules
If your project ships a Celery worker, declare it on the project
form's Worker command field. The deploy script writes a
sibling systemd unit at
/etc/systemd/system/<slug>-worker.service that runs
your specified command with the same EnvironmentFile and
working directory as the web service, and starts it after the web
service comes up. The same pattern applies to Celery beat, Channels
workers, and any other long-running background process.
For the message broker, point Celery at the platform's per-server
Valkey/Redis instance via
CELERY_BROKER_URL=redis://127.0.0.1:6379/0 on the project
environment-variable form. The broker is installed during first-deploy
provisioning and bound to loopback only — no external port is opened.
The result: a Celery deployment with zero extra infrastructure to
manage.
Database choice
The deploy script does not enforce a particular database. It does provision PostgreSQL during first-deploy provisioning because it is the right default for Django, but you can point at any database you can reach from the server. Common patterns:
- Local PostgreSQL. The default. Set
DATABASE_URL=postgres://<project-slug>:<password>@127.0.0.1/<project-slug>and the deploy script creates the role and database on first deploy. - Managed database provider (RDS, Neon, Supabase,
Crunchy Bridge). Set
DATABASE_URLto the provider's connection string and the deploy script skips the local provisioning step. Make sure outbound TCP from the server to the provider's host is allowed. - SQLite. Functional but not recommended past the
hobby tier — set
DATABASE_URL=sqlite:////srv/codesensei/<slug>/shared/db.sqlite3to keep the file in the shared directory so it survives across releases.