PR Preview Environments
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).
How it works
Section titled “How it works”- Labeling the PR
deploy-previewstarts thedeploy-previewjob. - 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. - 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. - It port-forwards the hub and Keycloak services from the cluster to the
runner’s
localhost, starts acloudflaredtunnel 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). - It posts the preview URL as a GitHub Deployment and a PR comment.
- 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.
- 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.
Triggering it
Section titled “Triggering it”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.
Lifetime and the extend-preview label
Section titled “Lifetime and the extend-preview label”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.
Hostnames and TLS
Section titled “Hostnames and TLS”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.
Authentication
Section titled “Authentication”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.
JupyterLab image
Section titled “JupyterLab image”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.
Tunnel and GitHub visibility
Section titled “Tunnel and GitHub visibility”- 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.
One-time Cloudflare setup
Section titled “One-time Cloudflare setup”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 thePREVIEW_DOMAINzone/account only. This is separate fromCLOUDFLARE_API_TOKEN, which belongs to the Pages account used bydocs.yml.
Security model
Section titled “Security model”- 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_TOKENis limited tocontents:read,pull-requests:write,issues:write,deployments:writefor this job. The Cloudflare API token can only edit Tunnels and DNS and read the zone, scoped to the singlePREVIEW_DOMAINzone, 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.