Deploy a Django SaaS
Hello-world deployments leave out everything that makes production difficult. 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. The setup transfers to almost any Django, Rails or Laravel application.
This is the path by hand, with kubectl and helm. On a PaaSbox cluster with the PaaSbox Platform
add-on (built in a lab, not published yet), one SaaSApplication replaces it: it runs the four roles, creates Postgres and
a Redis-compatible cache (Valkey), runs the migrations before each release and asks for the
certificate. Learn step 2, Deploy your first app, deploys UpCheck that way.
The shape of a real app
Section titled “The shape of a real app”One container image (ghcr.io/mitja/upcheck) runs four roles via command overrides:
| Role | What it runs | Kubernetes objects |
|---|---|---|
| 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).
What you’ll need
Section titled “What you’ll need”- A Kubernetes cluster; a single
cpx32node is enough for UpCheck. Pluskubectl,helm, and a domain you control. - The repo:
git clone https://github.com/mitja/upcheck && cd upcheck
On a PaaSbox cluster
Section titled “On a PaaSbox cluster”Without the platform add-on, follow the steps below with four changes. The repo’s overlay
deploy/overlays/paasbox/ was written for a cluster with ingress-nginx and Hetzner volumes:
- Step 1: install only the CloudNativePG operator. Leave out ingress-nginx: Traefik already
serves ports 80 and 443 on the server, and the cluster creates no Hetzner load balancer. Leave out
the Helm install of cert-manager and
deploy/cluster-issuer.yaml: switch on the cert-manager add-on instead, as in Expose an app with Ingress and TLS. - Step 2: point the A record at the server’s public IPv4 address, the one the cluster’s API name resolves to.
ingress.yamlin the overlay: setingressClassName: traefikand the annotationcert-manager.io/cluster-issuer: letsencrypt-production.kustomization.yamlin the overlay: two patches ask for the storage classhcloud-volumes, one for Postgres and one for Redis. Switch on the Hetzner volumes add-on (hcloud-csi), or change both tolocal-lvm-thin, the server’s own disk.
1. Install the cluster add-ons (once)
Section titled “1. Install the cluster add-ons (once)”Three add-ons every production cluster needs anyway. The Ingress + TLS guide explains each in depth:
# CloudNativePG operatorkubectl 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=truekubectl apply -f deploy/cluster-issuer.yaml2. Point DNS at the load balancer
Section titled “2. Point DNS at the load balancer”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)”kubectl create namespace upcheckkubectl -n upcheck create secret generic upcheck-secrets \ --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')"That is the minimum. UpCheck runs its billing through Polar, and without the optional
POLAR_* keys it runs with all accounts on the free plan, which is what you want for a
walkthrough. Note what is 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 as a separate step
Section titled “4. Deploy, and run migrations as a separate step”kubectl apply -k deploy/overlays/paasbox
# wait for Postgres, then migratekubectl -n upcheck wait --for=condition=Ready cluster/pg --timeout=420skubectl -n upcheck delete job upcheck-migrate --ignore-not-foundkubectl apply -f deploy/overlays/paasbox/migrate-job.yamlkubectl -n upcheck wait --for=condition=complete job/upcheck-migrate --timeout=300sThe migrate Job is deliberately not part of the kustomization: Kubernetes Jobs are immutable,
so “apply everything” cannot rerun them. Delete-then-apply per release is the reliable pattern.
It runs manage.py migrate and registers the beat schedule, and the web/worker/beat Deployments
start serving once the schema exists.
5. Verify
Section titled “5. Verify”kubectl -n upcheck get podscurl -I https://upcheck.example.com # your domain: valid Let's Encrypt cert + 200Open the site, sign up, create a monitor against example.com, and watch the worker check it on
schedule. That is the complete SaaS: web tier, async workers, scheduler, database, TLS.
6. Releasing updates
Section titled “6. Releasing updates”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:
kubectl apply -k deploy/overlays/paasboxkubectl -n upcheck delete job upcheck-migrate --ignore-not-foundkubectl apply -f deploy/overlays/paasbox/migrate-job.yamlkubectl -n upcheck rollout restart deploy/web deploy/worker deploy/beatTo run that from CI instead of a laptop, the Deploy from GitHub Actions guide shows the same commands driven by a short-lived kubeconfig.
Adapting this to your app
Section titled “Adapting this to your app”The UpCheck manifests are a template you can copy: 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 footnotes: set resource requests on every role (the repo’s manifests do; that is what lets the scheduler place more workloads on a small cluster), and when you are done with the walkthrough, clean up (below) or delete the cluster, rather than leaving its servers to bill.
Clean up
Section titled “Clean up”kubectl delete namespace upcheckhelm -n cert-manager uninstall cert-managerhelm -n ingress-nginx uninstall ingress-nginx # deletes the Hetzner load balancerOn a PaaSbox cluster the first line is enough. Switch the cert-manager add-on off on the cluster’s Add-ons tab if nothing else uses it.