Python и Django
Deploy Python apps and Django projects on Skiffly with Railpack — pip, uv, Poetry, PDM and Pipenv, the Python version, gunicorn and `PORT`, Postgres and Redis, migrations and `collectstatic`, static files and common errors.
Railpack's Python provider builds any Python project; Django gets a few extras. Flask and FastAPI are covered on their own page.
What Railpack detects#
| Looks at | Effect |
|---|---|
requirements.txt, pyproject.toml or Pipfile; or one of main.py, app.py, start.py, bot.py, hello.py, server.py | the service is a Python app |
requirements.txt → pip · pyproject.toml + poetry.lock → Poetry · + pdm.lock → PDM · + uv.lock → uv · Pipfile → Pipenv | how dependencies are installed |
RAILPACK_PYTHON_VERSION, then .python-version / .tool-versions / mise.toml, then runtime.txt, then Pipfile | the Python version (default 3.13) |
manage.py + django in the dependencies | Django: start command python manage.py migrate && gunicorn <project>.wsgi:application |
psycopg/psycopg2, mysqlclient, pycairo, pdf2image, pydub in the dependencies | the matching apt packages (libpq5, default-mysql-client, ffmpeg, …) are installed |
Railpack sets PYTHONUNBUFFERED=1 (logs appear immediately), PYTHONDONTWRITEBYTECODE=1 and PIP_DISABLE_PIP_VERSION_CHECK=1. Pin the version with .python-version (3.12) — the default moves with Railpack releases.
Port and server#
Skiffly sets PORT; the process must bind to 0.0.0.0:$PORT. Railpack's Django start command relies on gunicorn reading PORT (gunicorn binds to 0.0.0.0:$PORT when the variable is set). Make it explicit in your own start command anyway:
gunicorn mysite.wsgi --bind 0.0.0.0:$PORT --workers 2 --timeout 60Add gunicorn (or uvicorn + gunicorn with UvicornWorker for ASGI/Channels) to your dependencies; Railpack does not add servers. python manage.py runserver is for development only.
Django start command#
The Django template Skiffly ships uses this start command, which is a good default for most projects:
python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mysite.wsgi --bind 0.0.0.0:$PORTSet it in Settings → Deploy → Start command (or startCommand / start: in config as code) and replace mysite with your project package. RAILPACK_DJANGO_APP_NAME=mysite.wsgi does the same for Railpack's generated command if you prefer to keep it.
Settings that matter in production:
import os
DEBUG = os.environ.get("DEBUG", "") == "1"
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = [os.environ.get("SKIFFLY_PUBLIC_DOMAIN", "localhost"), "app.example.com"]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DATABASES = {"default": dj_database_url.config(conn_max_age=60)} # reads DATABASE_URLSKIFFLY_PUBLIC_DOMAIN is set automatically once the service has a domain. Generate SECRET_KEY once (skiffly variables set SECRET_KEY=$(openssl rand -hex 32) or generate("hex64") in config as code).
Postgres and Redis#
skiffly deploy --template postgres
skiffly deploy --template redis
skiffly variables set 'DATABASE_URL=${{Postgres.DATABASE_URL}}' 'REDIS_URL=${{Redis.REDIS_URL}}'DATABASE_URL is postgresql://postgres:…@postgres:5432/app on the private network — dj-database-url or DATABASES["default"] = {"ENGINE": "django.db.backends.postgresql", …} from urllib.parse both work. Install psycopg[binary] (or psycopg2-binary); Railpack adds libpq when it sees the package. Celery and django-redis take REDIS_URL as-is. SQLite works too, but only with a volume mounted where the file lives, and with one replica.
Migrations#
There is no release phase. Either keep python manage.py migrate in the start command (it runs before gunicorn on every deployment; startup gets up to five minutes before the health check gives up), or run it as a one-off from your machine when you want to control the moment:
skiffly ssh -- python manage.py migrate
skiffly ssh -- python manage.py createsuperuserWith several replicas each container runs the start command; Django's migration table makes concurrent migrate calls safe for ordinary migrations, but long data migrations belong in a one-off.
Static and media files#
- Static:
collectstaticin the start command (above) or in the build command, then serve with WhiteNoise (whitenoise.middleware.WhiteNoiseMiddleware,STATIC_ROOT = BASE_DIR / "staticfiles"). Running it at build time (buildCommand: python manage.py collectstatic --noinput) works because every service variable is available to the build; make sureSECRET_KEYis set before the first build. - Media / uploads: the container's disk is ephemeral. Mount a volume at
MEDIA_ROOT(skiffly volume add --mount-path /app/media), or use S3-compatible storage (django-storageswith theminiotemplate or an external bucket).
Workers and scheduled jobs#
Celery or RQ workers are a second service from the same repository with the start command celery -A mysite worker -l info and no domain; Celery beat is a third one, or a service with a cron schedule whose start command is a management command (python manage.py send_digests). Services share variables through ${{shared.NAME}} or references.
Health check#
path("healthz/", lambda r: HttpResponse("ok")) and set the healthcheck path to /healthz/. Because ALLOWED_HOSTS is checked on every request, the probe's Host header (the service's private address) must be accepted: django-healthcheck-style middleware that answers before the host check, or ALLOWED_HOSTS = ["*"] behind Skiffly's proxy, are both common.
Common problems#
| Symptom | Fix |
|---|---|
DisallowedHost at / | add the domain (and SKIFFLY_PUBLIC_DOMAIN) to ALLOWED_HOSTS |
CSRF verification failed. Origin checking failed | CSRF_TRUSTED_ORIGINS = ["https://app.example.com"] |
FAILED after 5 minutes, gunicorn logs Listening at: http://127.0.0.1:8000 | bind to 0.0.0.0:$PORT |
ModuleNotFoundError: No module named 'mysite' | wrong --chdir/package in the start command, or the service's root directory is not the Django project root |
psycopg2 build fails (pg_config not found) | use psycopg[binary] / psycopg2-binary |
collectstatic fails with SECRET_KEY empty | set SECRET_KEY before deploying |
| Static files 404 in production | DEBUG=False disables Django's static serving: add WhiteNoise or serve from a CDN |
| Uploads disappear after a deploy | MEDIA_ROOT is on the ephemeral disk: mount a volume or use object storage |
Example#
import { defineSkiffly, generate, github, postgres, project, redis, service } from "@skiffly/config";
export default defineSkiffly(() => {
const db = postgres("db");
const cache = redis("cache");
const web = service("web", {
source: github("acme/mysite"),
start: "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mysite.wsgi --bind 0.0.0.0:$PORT",
healthcheck: "/healthz/",
port: 8000,
env: { DATABASE_URL: db.env.DATABASE_URL, REDIS_URL: cache.env.REDIS_URL, SECRET_KEY: generate("hex64"), DJANGO_SETTINGS_MODULE: "mysite.settings" },
domain: true,
});
const worker = service("worker", { source: github("acme/mysite"), start: "celery -A mysite worker -l info", env: { DATABASE_URL: db.env.DATABASE_URL, REDIS_URL: cache.env.REDIS_URL, SECRET_KEY: web.env.SECRET_KEY } });
return project("mysite", { resources: [db, cache, web, worker] });
});Tutorial: Django + Postgres + Redis. Landing: /deploy/django.