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#
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:
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 statusExpected: 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:
mysite → Settings → Deploy → start command:
python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mysite.wsgi --bind 0.0.0.0:$PORT --workers 2Healthcheck path: /healthz/.
5. Deploy#
skiffly up -s mysite
skiffly logs -s mysite -fExpected 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 createsuperuserOpen /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 environmentAdd the worker and the cron job to the generated file:
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 applyExpected: 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 208. 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#
| Symptom | Fix |
|---|---|
DisallowedHost | ALLOWED_HOSTS is a fixed list without the domain; SKIFFLY_PUBLIC_DOMAIN is only set after skiffly domain |
CSRF verification failed on the admin login | CSRF_TRUSTED_ORIGINS lacks the domain: set APP_HOST or redeploy after skiffly domain |
KeyError: 'SECRET_KEY' during collectstatic | variables 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 Redis | REDIS_URL not set on the worker; references are per service |
Cron service shows FAILED | the command exited non-zero: skiffly logs -s nightly |
Next: Python & Django guide · Services: cron jobs · Config as code