Skip to content

PR Preview Environments

Updated 6 min read

The k8s-preview GitHub Actions workflow (.github/workflows/k8s-preview.yaml) deploys this PR’s chart into an ephemeral kind cluster running the full Nebari platform stack (Keycloak + Nebari Operator + Envoy Gateway, via nebari-dev/action-nebari-sandbox), then exposes it through a per-PR Cloudflare Tunnel behind Cloudflare Access (GitHub SSO).

GitHub Actions runner

opens the link

posts

deploy-preview label added

Build hub image

from this PR

kind cluster +

Keycloak, Operator, Envoy Gateway

helm install

this PR's chart

port-forward hub + Keycloak

to runner localhost

cloudflared tunnel

Cloudflare DNS record

pr-<n>-....<domain>

Cloudflare Access

(GitHub SSO gate)

PR comment +

GitHub Deployment

Reviewer's browser

  1. Labeling the PR deploy-preview starts the deploy-preview job.
  2. It builds the hub image from this PR and boots a kind cluster running Keycloak, the Nebari Operator, and Envoy Gateway (nebari-dev/action-nebari-sandbox), then side-loads the built image into it.
  3. It points the chart’s JupyterLab image at this PR’s own build (see “JupyterLab image” below), then installs the chart with helm upgrade --install, patches Keycloak’s own hostname to match the public route, and waits for the Operator to finish provisioning the OIDC client.
  4. It port-forwards the hub and Keycloak services from the cluster to the runner’s localhost, starts a cloudflared tunnel mapping the public preview hostnames to those local ports, and points a Cloudflare DNS record at the tunnel. The tunnel step shows kubectl’s output in its log, restarts a port-forward that exits after it was forwarding (lost connection to the pod), and fails the run if one exits before it ever forwarded (kubectl’s error says why).
  5. It posts the preview URL as a GitHub Deployment and a PR comment.
  6. A reviewer opening the link authenticates through Cloudflare Access (GitHub SSO) before any request reaches the tunnel. Past Access, traffic flows tunnel -> runner port-forward -> cluster service -> pod, and the hub runs its own OAuth flow against Keycloak so it knows who signed in.
  7. After 20 minutes (or on extend-preview/label removal), the tunnel closes, the DNS record and tunnel are deleted, and the runner (with its kind cluster) is torn down when the job ends.

Add the deploy-preview label to a PR. That label gates who can trigger a deploy (GitHub label permissions). Fork PRs are skipped: workflow runs from forked repositories do not receive repository secrets. Removing the label runs the cleanup-preview job, which cancels any in-flight deploy for that PR, marks the GitHub deployment inactive, and posts a “stopped” comment. Pushing a new commit while the label is on cancels the in-flight deploy and starts a fresh one. The fresh run starts only after the cancelled run’s cleanup (which updates the comment and drops the label) has finished; it then puts the label back and takes the comment over. A deploy cancelled any other way posts a “cancelled” comment and drops the label.

Whether a finishing run updates the comment and drops the label depends only on the label: present means the run still owns the preview, absent means a person removed it and the cleanup-preview job owns it.

A preview is live for 20 minutes by default (the tunnel step’s own timeout, kept under the job’s 90-minute timeout so it self-ends cleanly instead of GitHub reporting a false “cancelled” run). Adding the extend-preview label resets the deadline to 20 minutes from that moment; it can be added any number of times, but each add is a reset, not an addition on top of what’s left. The label is polled from inside the running tunnel step (scripts/preview/tunnel.py), not by the workflow run the label event starts. Only a push while deploy-preview is on, or a person adding or removing deploy-preview, shares the PR’s concurrency group. Every other event gets a group of its own, so it can neither cancel the live preview nor replace a queued stop or redeploy (a group keeps one pending run). Label changes the workflow makes itself start no runs.

Preview hostnames stay single-level (pr-<number>-data-science-pack.<domain>): the free Cloudflare Universal SSL certificate on the preview zone only covers the zone itself plus one wildcard level.

nebariapp.enabled=true routes login through a real Operator-provisioned Keycloak client, the same auth path production deployments use. The preview Keycloak is a fresh realm with no users and no link to any external identity provider, so the workflow seeds one account, reviewer / admin, for the hub login. Cloudflare Access (GitHub sign-in) in front of the tunnel is the actual security boundary, so a simple known password for that account is acceptable.

The hub image is built and side-loaded locally by this workflow, but the JupyterLab image users actually spawn is built separately by build-images.yaml (multi-arch, pushed to ghcr.io/quay.io on the same PR trigger). scripts/preview/pr_image.py rewrites values.yaml’s singleuser and per-profile image refs, in the ephemeral checkout only, to that PR’s pr-<number> tag before the chart deploys. If no such tag exists on the registry, values.yaml is left as is and the preview runs the pinned image.

The two builds aren’t ordered against each other. A pod only pulls the image when a reviewer actually spawns a server, by which point build-images.yaml has usually finished; if not, kubelet retries the pull automatically once the tag exists; no action is needed either way.

  • Named Tunnel: each PR gets its own named Cloudflare Tunnel sitting behind Access, rather than an anonymous quick tunnel.
  • GitHub Deployment: a Vercel-style deployment box is created via the GitHub Deployments API, separate from the sticky PR comment (which doesn’t move once posted). required_contexts: [] keeps this preview link from gating on unrelated checks like lint or test.
  • Comment timestamps: rendered with GitHub’s <relative-time> web component, so “Expires in 12 minutes” keeps ticking client-side with no manual timezone math or re-editing needed.
  • On expiry: the deployment is marked inactive and, if a “ready” comment was posted, it’s re-rendered to the expired state.
  • On failure: if the deploy dies before a “ready” comment exists, or the tunnel step fails after it (cloudflared exits, or a port-forward cannot be restarted), the comment is re-rendered to a failed state linking the run.

Configured once in the Cloudflare Zero Trust dashboard for this repository:

  • Repository variable PREVIEW_DOMAIN: the zone name (e.g. openteams.app).
  • A Cloudflare Access application for *.<PREVIEW_DOMAIN>, GitHub as the identity provider, and a policy scoped to this org.
  • Secret CLOUDFLARE_TUNNEL_ACCOUNT_ID: that Cloudflare account’s ID.
  • Secret CLOUDFLARE_TUNNEL_API_TOKEN: Tunnel:Edit + Zone:Read + DNS:Edit, scoped to the PREVIEW_DOMAIN zone/account only. This is separate from CLOUDFLARE_API_TOKEN, which belongs to the Pages account used by docs.yml.
  • Public exposure is gated before it reaches the cluster: Cloudflare Access sits in front of the tunnel and requires a GitHub SSO login plus an org policy match before any request reaches cloudflared. Nothing in the preview cluster is reachable without passing that gate.
  • kind shares the runner’s Docker daemon: a container escaping its pod gets host-level Docker access on that ephemeral runner only, not on any shared or production infrastructure, and the runner is destroyed with the job.
  • Fork PRs are skipped: workflow runs from forked repositories do not receive repository secrets.
  • Token scope: GITHUB_TOKEN is limited to contents:read, pull-requests:write, issues:write, deployments:write for this job. The Cloudflare API token can only edit Tunnels and DNS and read the zone, scoped to the single PREVIEW_DOMAIN zone, not account-wide.
  • Nothing outlives the run: the tunnel, its DNS record, the Keycloak reviewer account, and the kind cluster all exist only for the job’s lifetime, or until the preview expires or is stopped early.