Skip to content

Deploy your first app

This is the second step of the Learn path. You run upcheck, a Django app with a Celery worker, a scheduler, Postgres and a cache, on the cluster from step 1, reachable over HTTPS under a name of yours. It takes one file, which the next steps apply to other clusters unchanged except for the name.

upcheck on upcheck-prod, with Postgres run by CloudNativePG, a Valkey cache, and its migrations run before each new version starts. All of it is one file, upcheck.yaml, holding a SaaSApplication: the app’s description of what it needs. The PaaSbox Platform add-on turns it into Deployments, Jobs, a database and an Ingress with a certificate.

  • upcheck-prod from step 1, on release 2026.10.2 or newer: the PaaSbox Platform comes with that release. The header of the cluster’s page names its release; if it names an older one, upgrade the cluster first.
  • An image of upcheck in a registry the server can pull from. Build it from github.com/mitja/upcheck and push it. For a private registry, create a pull Secret in the namespace upcheck and name it under spec.imagePullSecrets.
  • A host name for the app in a DNS zone you can write, for example upcheck.example.com. The portal creates one DNS name per cluster, for its Kubernetes API, and none for apps. A name under your team’s <team>.paasbox.app will work too once that service opens.
  • The admin kubeconfig of upcheck-prod, from step 1.
  1. On the cluster’s Overview, the Nodes table shows the server’s IPv4 address under Address.

  2. In your DNS zone, create an A record from upcheck.example.com to that address. Let’s Encrypt checks the name over HTTP on port 80, which the cluster’s firewall opens to everyone, so the certificate can be issued only once the name points at the server.

  1. Open the cluster’s Add-ons tab and find PaaSbox Platform (paasbox-platform). Tick On. Keep the Profile saas-http01: Postgres with backups and restores, and a Let’s Encrypt certificate per app over HTTP-01. Leave Observability off: with Postgres it needs a server with 8 GB, and the page refuses it on a cpx22. Enter your address under ACME e-mail: Let’s Encrypt registers the account under it, and the add-on is not saved without it. Leave Let’s Encrypt at production for a certificate browsers trust; staging tries a setup out without production’s rate limits.

  2. Under In the same save, tick Switch Flux on too. The platform installs its parts as Flux Kustomizations, so it needs the Flux add-on. If the cert-manager add-on is on, tick Switch cert-manager off in the same save: the platform brings its own cert-manager, and the two cannot run side by side. Choose Save.

  3. The add-on’s line says what the cluster reports, about 30 seconds behind. It stays pending while Flux installs cert-manager, CloudNativePG and the issuers, and turns applied when the platform reports ready. In the lab on 2026-10-11, Flux alone was applied 71 seconds after its save, and Flux with the platform’s minimal profile in one save after 2 minutes 53 seconds. Check it from your computer:

    Terminal window
    kubectl get platform
    kubectl -n flux-system get kustomizations

    One Platform, named after the cluster, with READY True and the profile saas-http01; the Kustomizations platform-cert-manager, platform-tls and platform-postgres are ready.

If the add-on stays pending. A part of the platform that failed once is tried again only after an hour. In the lab that happened when the cert-manager add-on had been on before: cert-manager’s webhook changed its certificate while Postgres was being set up. Find the Kustomization whose READY is False and ask Flux to try it now, the inner one first (tls, postgres), then its platform- one:

Terminal window
kubectl -n flux-system get kustomizations
kubectl -n flux-system annotate --overwrite kustomization tls reconcile.fluxcd.io/requestedAt="$(date +%s)"
kubectl -n flux-system annotate --overwrite kustomization platform-tls reconcile.fluxcd.io/requestedAt="$(date +%s)"

In the lab a cpx22 with Flux and saas-http01 and no app had 1.4 GiB of memory available, and upcheck used 559 to 606 MiB: room for one app of upcheck’s size, not for two. The release plans with 170 MiB for Flux and 300 MiB for the platform, more than the lab measured.

Save this as upcheck.yaml, with your image and your host name:

apiVersion: v1
kind: Namespace
metadata:
name: upcheck
---
apiVersion: platform.paasbox.com/v1alpha1
kind: SaaSApplication
metadata:
name: upcheck
namespace: upcheck
spec:
image: registry.example.com/upcheck:1 # your build of upcheck
framework:
profile: django
components:
- name: web
runtime: requestDriven
port: 8000
- name: worker
command: [celery, -A, project, worker, -l, INFO, --concurrency, "2"]
- name: beat
runtime: singleton # never two: they would schedule every task twice
command: [celery, -A, project, beat, -l, INFO, --scheduler, django_celery_beat.schedulers:DatabaseScheduler]
data:
postgres:
class: small
cache:
class: small
release:
preDeploy:
- name: migrate
command: [python, manage.py, migrate, --noinput]
- name: bootstrap
command: [python, manage.py, bootstrap_celery_tasks]
env:
- name: DJANGO_SETTINGS_MODULE
value: project.settings_production
exposure:
hostname: upcheck.example.com # your name; each stage gets its own
tls:
clusterIssuer: letsencrypt-http01
  • framework.profile: django sets SECRET_KEY (generated once), ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS, and probes the app with its public host name.
  • components become Deployments; web gets the Service upcheck-web on port 80, in front of the container’s port 8000. beat runs as one replica that is stopped before a new one starts.
  • data creates the Postgres database upcheck-pg with one instance and a 2 GiB volume, bound as DATABASE_URL, and the Valkey cache, bound as REDIS_URL.
  • release.preDeploy runs the migrations as a Job before any component gets a new image.
  • exposure creates an Ingress through Traefik with a certificate from letsencrypt-http01, the platform’s issuer; it asks Let’s Encrypt’s service that the add-on’s Let’s Encrypt option names. Instead of a host name per app, you can set the add-on’s Default domain, for example apps.example.com with an A record for *.apps.example.com, and write exposure: {}: the app is then served as upcheck.apps.example.com. The lab ran upcheck this way.
  1. Apply the file and watch the app:

    Terminal window
    kubectl apply -f upcheck.yaml
    kubectl -n upcheck get saasapp upcheck -w
  2. Follow the phase. Pending: Postgres and the cache are not ready yet, and nothing else runs. Releasing: the Job upcheck-release-<id> runs migrate and bootstrap; no component exists before the first release has passed its hooks, so nothing serves on a database that is not migrated. Ready: every component has its replicas. Failed means a hook failed; kubectl -n upcheck describe saasapp upcheck shows every change of phase as an Event.

  3. When the phase is Ready, the URL column shows https://upcheck.example.com. Open it in your browser. In the lab on 2026-10-11 upcheck was Ready 82 seconds after kubectl apply, its release Job finished before its Deployments were created.

For an app that is one image, Traefik and the cert-manager add-on are enough. On the Add-ons tab, switch on cert-manager with your ACME e-mail and the Challenge http01. An Ingress annotated kubernetes.io/tls-acme: "true" then gets a certificate from the Default issuer:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
kubernetes.io/tls-acme: "true"
spec:
ingressClassName: traefik
rules:
- host: web.example.com
http:
paths:
- {path: /, pathType: Prefix, backend: {service: {name: web, port: {number: 80}}}}
tls:
- {hosts: [web.example.com], secretName: web-tls}

Built In the lab the add-on’s issuers were ready in 21 seconds, and a certificate from Let’s Encrypt’s staging service was issued over Traefik in 31 seconds. A Deployment and a Service for the image, and Postgres or Redis next to it, are in Run your apps. This path and the platform exclude each other on one cluster.

upcheck in production, under your name, over HTTPS, from one file. Keep it running: step 3 has an agent test it in a cluster of its own, and step 4 deploys the same file to staging. Its web runs one replica: while a new version rolls out, the app answered with errors for about 30 seconds in the lab.

The database is a volume on the server’s disk, and a snapshot of the cluster does not hold it (step 5 shows this). Before real users depend on it, give it backups of its own: data.postgres.backup sends its write-ahead log to an S3 bucket continuously and takes a base backup every day. Give each stage its own folder: a folder that already holds another database’s log is refused.