Skip to content

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.

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

RoleWhat it runsKubernetes objects
webgunicorn + WhiteNoiseDeployment + Service
workerCelery worker (the actual uptime checks)Deployment
beatCelery beat (schedules the checks)Deployment, single replica
migratemanage.py migrate + task bootstrapone-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 Kubernetes cluster; a single cpx32 node is enough for UpCheck. Plus kubectl, helm, and a domain you control.
  • The repo: git clone https://github.com/mitja/upcheck && cd upcheck

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.yaml in the overlay: set ingressClassName: traefik and the annotation cert-manager.io/cluster-issuer: letsencrypt-production.
  • kustomization.yaml in the overlay: two patches ask for the storage class hcloud-volumes, one for Postgres and one for Redis. Switch on the Hetzner volumes add-on (hcloud-csi), or change both to local-lvm-thin, the server’s own disk.

Three add-ons every production cluster needs 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 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”
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” 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.

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. That is the complete SaaS: 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 Deploy from GitHub Actions guide shows the same commands driven by a short-lived kubeconfig.

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.

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

On 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.