Pular para o conteúdo

Buscar na documentação

Buscar na documentação do Skiffly

Django + Postgres + Redis

Step by step: a Django project with gunicorn, a Postgres database, Redis for cache and Celery, a worker service and a cron job on Skiffly — CLI, dashboard or MCP, with the expected result of every step.

What you build: a project mysite with four services — web (Django + gunicorn), worker (Celery), Postgres and Redis — plus a nightly management command on a cron schedule. Migrations and collectstatic run when the web container starts. About 20 minutes.

You need: a Django project on GitHub with requirements.txt (or pyproject.toml) that includes gunicorn, psycopg[binary], dj-database-url, whitenoise, redis and celery; the skiffly CLI (npm i -g skiffly && skiffly login).

1. Production settings#

mysite/settings.py
import os, dj_database_url
 
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = os.environ.get("DEBUG") == "1"
# Only your domains reach the container (the edge routes by host), and the health probe uses the container's
# address as Host — so "*" is safe here; keep CSRF origins explicit.
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in [os.environ.get("SKIFFLY_PUBLIC_DOMAIN"), os.environ.get("APP_HOST")] if h]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
 
DATABASES = {"default": dj_database_url.config(conn_max_age=60)}      # DATABASE_URL
CACHES = {"default": {"BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379")}}
CELERY_BROKER_URL = os.environ.get("REDIS_URL")
 
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}}

A health endpoint:

mysite/urls.py
from django.http import HttpResponse
urlpatterns = [path("healthz/", lambda r: HttpResponse("ok")), ...]

Pin Python with a .python-version file (3.12). Commit and push.

2. Project, database, cache#

cd mysite
skiffly init --name mysite
skiffly deploy --template postgres      # service "Postgres": postgres:17, 10 GB volume, DATABASE_URL
skiffly deploy --template redis         # service "Redis": redis:7, 2 GB volume, REDIS_URL
skiffly status

Expected: both services SUCCESS. (Dashboard: New service → Template twice. MCP: ask the agent to create Postgres and Redis services; it uses create-service + create-volume + set-variables.)

3. Variables for the web service#

Set them before the first build so collectstatic and Django can boot:

skiffly up -y -d                       # creates the service "mysite" (empty until the first build finishes) — Ctrl-C is fine
skiffly variables set -s mysite --skip-deploys \
  SECRET_KEY="$(openssl rand -hex 32)" \
  DJANGO_SETTINGS_MODULE=mysite.settings \
  'DATABASE_URL=${{Postgres.DATABASE_URL}}' \
  'REDIS_URL=${{Redis.REDIS_URL}}'

--skip-deploys stores the values without a redeploy for each call; the next skiffly up picks them all up.

4. Start command and health check#

Railpack would guess python manage.py migrate && gunicorn mysite.wsgi:application. Make it explicit with collectstatic and the bind address:

mysiteSettings → Deploy → start command:

python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mysite.wsgi --bind 0.0.0.0:$PORT --workers 2

Healthcheck path: /healthz/.

5. Deploy#

skiffly up -s mysite
skiffly logs -s mysite -f

Expected in the build log: [railpack] detected python, pip install -r requirements.txt. In the runtime log: Applying … OK, N static files copied, Listening at: http://0.0.0.0:8080. Status SUCCESS once /healthz/ answers.

skiffly domain -s mysite                      # https://mysite-x1y2.skiffly.cloud
skiffly ssh -s mysite -- python manage.py createsuperuser

Open /admin/ on the domain and sign in.

6. The Celery worker#

A second service from the same repository, same variables, no domain. The fastest path is config as code, which also captures everything above:

skiffly config init                            # .skiffly/skiffly.ts from the current environment

Add the worker and the cron job to the generated file:

.skiffly/skiffly.ts
import { defineSkiffly, github, postgres, preserve, project, redis, service } from "@skiffly/config";
 
export default defineSkiffly(() => {
  const db = postgres("Postgres");
  const cache = redis("Redis");
  const env = {
    SECRET_KEY: process.env.SECRET_KEY ?? preserve(),
    DJANGO_SETTINGS_MODULE: "mysite.settings",
    DATABASE_URL: db.env.DATABASE_URL,
    REDIS_URL: cache.env.REDIS_URL,
  };
  const web = service("mysite", {
    source: github("acme/mysite"),
    start: "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mysite.wsgi --bind 0.0.0.0:$PORT --workers 2",
    healthcheck: "/healthz/",
    env,
    domain: true,
  });
  const worker = service("worker", {
    source: github("acme/mysite"),
    // every long-running service is probed on its port: the http.server keeps the rollout healthy
    start: "sh -c 'python -m http.server $PORT --bind 0.0.0.0 -d /tmp & exec celery -A mysite worker -l info --concurrency 2'",
    env: { ...env, SECRET_KEY: web.env.SECRET_KEY },
  });
  const nightly = service("nightly", {
    source: github("acme/mysite"),
    start: "python manage.py send_digests",
    cron: "0 3 * * *",
    env: { ...env, SECRET_KEY: web.env.SECRET_KEY },
  });
  return project("mysite", { resources: [db, cache, web, worker, nightly] });
});
skiffly config plan          # + service worker, + service nightly
skiffly config apply

Expected: worker shows SUCCESS with celery@… ready. in its log (the python -m http.server in front of Celery exists only so the rollout probe on the service port passes — a service that listens on nothing is marked FAILED after five minutes); nightly shows the next run time and no running container between ticks. (Without config as code: New service → GitHub repo twice in the dashboard, then set the start command, variables and the cron schedule in each service's settings.)

7. Check the queue#

skiffly ssh -s mysite -- python manage.py shell -c "from mysite.tasks import ping; print(ping.delay().get(timeout=10))"
skiffly logs -s worker -n 20

8. Media files#

Uploads written to MEDIA_ROOT disappear on the next deploy. Either mount a volume — skiffly volume add -s mysite --mount-path /app/media --size 5 and MEDIA_ROOT = "/app/media" (one replica) — or use django-storages with an S3-compatible bucket (skiffly deploy --template minio).

Troubleshooting#

SymptomFix
DisallowedHostALLOWED_HOSTS is a fixed list without the domain; SKIFFLY_PUBLIC_DOMAIN is only set after skiffly domain
CSRF verification failed on the admin loginCSRF_TRUSTED_ORIGINS lacks the domain: set APP_HOST or redeploy after skiffly domain
KeyError: 'SECRET_KEY' during collectstaticvariables were set after the build started: skiffly up again
django.db.utils.OperationalError: could not translate host name "postgres"the service is in another environment, or the reference was set on the wrong service
Worker logs Connection refused to RedisREDIS_URL not set on the worker; references are per service
Cron service shows FAILEDthe command exited non-zero: skiffly logs -s nightly

Next: Python & Django guide · Services: cron jobs · Config as code