Skip to content

Deploy a Django SaaS

Hello-world deployments hide everything that makes production interesting. This guide deploys UpCheck — a real, open-source Django SaaS (an uptime monitor with signups, background checks, and public status pages) — end to end: database, background workers, scheduled tasks, migrations, HTTPS. It’s the same setup that runs the live instance at upcheck.paasbox.com, and the shape generalizes to almost any Django (or Rails, or Laravel) application.

One container image (ghcr.io/mitja/upcheck) runs four roles via command overrides:

Role What it runs Kubernetes shape
web gunicorn + WhiteNoise Deployment + Service
worker Celery worker (the actual uptime checks) Deployment
beat Celery beat (schedules the checks) Deployment, single replica
migrate manage.py migrate + task bootstrap one-off Job, per release

Plus PostgreSQL via the CloudNativePG operator and a single-instance Redis as the Celery broker. The manifests live in the repo under deploy/ as a kustomize base with overlays (kind for local, paasbox for production).

  • A cluster (quickstart — one cpx32 worker is plenty for UpCheck), kubectl, helm, and a domain you control.
  • The repo: git clone https://github.com/mitja/upcheck && cd upcheck

Three add-ons every production cluster wants anyway — the Ingress + TLS guide explains each in depth:

Terminal window
# CloudNativePG operator
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml
# ingress-nginx — provisions a Hetzner load balancer (on your Hetzner bill)
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.annotations."load-balancer\.hetzner\.cloud/location"=nbg1
# cert-manager + a Let's Encrypt issuer (edit the email in the file first)
helm upgrade --install cert-manager cert-manager \
--repo https://charts.jetstack.io \
--namespace cert-manager --create-namespace \
--set crds.enabled=true
kubectl apply -f deploy/cluster-issuer.yaml
Terminal window
kubectl -n ingress-nginx get svc ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'

Create an A record for your host pointing at that IP, and change the hostname in deploy/overlays/paasbox/ from upcheck.paasbox.com to your own domain.

3. Create the secrets (out of band, never committed)

Section titled “3. Create the secrets (out of band, never committed)”
Terminal window
kubectl create namespace upcheck
kubectl -n upcheck create secret generic upcheck-secrets \
--from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')"

That’s the minimum — UpCheck runs its billing through Polar, and without the optional POLAR_* keys it simply runs with all accounts on the free plan, which is exactly what you want for a walkthrough. Note what’s not here: no database password. CNPG generates the pg-app Secret itself, and the app consumes its uri key as DATABASE_URL.

4. Deploy — and run migrations the honest way

Section titled “4. Deploy — and run migrations the honest way”
Terminal window
kubectl apply -k deploy/overlays/paasbox
# wait for Postgres, then migrate
kubectl -n upcheck wait --for=condition=Ready cluster/pg --timeout=420s
kubectl -n upcheck delete job upcheck-migrate --ignore-not-found
kubectl apply -f deploy/overlays/paasbox/migrate-job.yaml
kubectl -n upcheck wait --for=condition=complete job/upcheck-migrate --timeout=300s

The migrate Job is deliberately not part of the kustomization: Kubernetes Jobs are immutable, so “apply everything” can’t rerun them. Delete-then-apply per release is the boring, reliable pattern — it runs manage.py migrate and registers the beat schedule, and the web/worker/beat Deployments start serving once the schema exists.

Terminal window
kubectl -n upcheck get pods
curl -I https://upcheck.example.com # your domain: valid Let's Encrypt cert + 200

Open the site, sign up, create a monitor against example.com, and watch the worker check it on schedule — a complete SaaS loop: web tier, async workers, scheduler, database, TLS.

CI publishes ghcr.io/mitja/upcheck:latest and :sha-<commit> on every commit. For reproducible deploys, pin newTag in overlays/paasbox/kustomization.yaml (and the image in its migrate-job.yaml) to a sha- tag, then:

Terminal window
kubectl apply -k deploy/overlays/paasbox
kubectl -n upcheck delete job upcheck-migrate --ignore-not-found
kubectl apply -f deploy/overlays/paasbox/migrate-job.yaml
kubectl -n upcheck rollout restart deploy/web deploy/worker deploy/beat

To run that from CI instead of a laptop, the CI/CD with GitHub Actions guide shows the same commands driven by a short-lived kubeconfig.

The UpCheck manifests are a template worth stealing: one image with command overrides per role, a CNPG cluster with the generated pg-app secret, secrets created out of band, an immutable-Job migration step, and an Ingress with the cert-manager.io/cluster-issuer annotation. Swap the image, the hostname, and the env vars, and the same deploy/ tree runs most web applications.

Two honest footnotes: set resource requests on every role (the repo’s manifests do — it’s what lets the scheduler pack a small cluster efficiently), and when your walkthrough cluster is idle, hibernate it rather than leaving it to bill.

Terminal window
kubectl delete namespace upcheck
helm -n cert-manager uninstall cert-manager
helm -n ingress-nginx uninstall ingress-nginx # deletes the Hetzner load balancer