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.txt at the repository root (or wherever you pointed the requirements file path).
  • A manage.py at the same level as requirements.txt.
  • A Django settings module addressable as a dotted Python path — e.g. config.settings.production.
  • A STATIC_ROOT defined in the settings module so collectstatic has 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_HOSTS in the project's Environment vars. For the rendered <uuid>.apps.codesensei.cloud hostname you also need .apps.codesensei.cloud in the list, or ALLOWED_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_URL points at 127.0.0.1 but 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 = False with no SECRET_KEY set, or STATIC_URL missing.

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_URL to 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.sqlite3 to keep the file in the shared directory so it survives across releases.