Guides
Public preview environments
For an environment per pull request on a public URL. Napwake pauses each preview once it shows no sign of use, and only people wake it. Crawlers, scanners and API calls wake nothing.
- Asleep · paused
Asks first
A browser gets the asleep page, with a Wake it button. Robots get a
503. - A person clicks Wake it
- Waking up · resuming
Woken by a person
The waking-up page carries on.
- A pod is Ready
- Awake · running
Up
The page they opened loads.
On the public internet, most requests are robots. Crawlers, link previewers and scanners try every address, and many pass for browsers. A preview that wakes on any request wakes every few minutes, and hardly ever stays paused.
Its hostname doesn’t stay hidden. Once it has an HTTPS certificate, it’s in public certificate logs within minutes, and scanners reach it by name.
With ask, a person’s browser gets a page with a Wake it button, and only the click wakes the preview. Your tooling wakes it with an annotation. Behind a VPN, single sign-on or an allowlist, keep the default, request (Environments on the public internet).
-
Opt in each preview’s namespace, asking first.
namespace.yaml apiVersion: v1kind: Namespacemetadata:name: preview-42labels:napwake.dev/managed: "true"annotations:napwake.dev/wake: "ask"Leave
replicasout of the workloads’ manifests, and Kubernetes starts a new Deployment at one replica. A deploy that sets them scales a paused preview up, which wakes it. A GitOps tool that sets them undoes every pause (GitOps tools).An Application that creates the namespace, with
CreateNamespace=true, sets them inmanagedNamespaceMetadata. It must leave the replicas to Napwake too, or it scales the preview up after every pause.application.yaml apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:name: preview-42namespace: argocdspec:project: defaultsource:repoURL: https://github.com/your-org/previews.gittargetRevision: mainpath: previews/preview-42destination:server: https://kubernetes.default.svcnamespace: preview-42ignoreDifferences:- group: appskind: DeploymentjsonPointers:- /spec/replicassyncPolicy:automated: {}managedNamespaceMetadata:labels:napwake.dev/managed: "true"annotations:napwake.dev/wake: "ask"syncOptions:- CreateNamespace=true- RespectIgnoreDifferences=trueArgo CD sets
managedNamespaceMetadataonly in a sync withCreateNamespace=true. For a namespace it created before, see the Argo CD tab of A whole namespace. For StatefulSets, see With GitOps.With Flux, or anything that applies a kustomization, put the Namespace in it, as
namespace.yamlbeside it.kustomization.yaml apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: preview-42resources:- namespace.yaml- ../baseLeave
replicasout of the base’s manifests. Flux sets the replicas a manifest has, so it would scale the preview up after every pause. That’s aGitOpsConflict, which holds off the next pause for an hour (Flux).values.yaml defaults:wake: askEvery opted-in workload asks, whatever its namespace, unless its own annotations or its namespace’s say
"request".managedworkload.yaml spec:wake:mode: AskFor a ManagedWorkload you wrote. The spec spells it
Ask, and the annotationask.A workload’s own
napwake.dev/wakewins over its namespace’s, and that over the Helm default (Which setting wins). The annotation takes exactly"request"or"ask". Any other value asks too, with anInvalidSettingwarning event, so a typo never lets robots wake a preview. -
Check it once the preview has paused, after an hour with no activity by default.
Terminal window kubectl get managedworkloads -n preview-42 \-o custom-columns='NAME:.metadata.name,WAKE:.spec.wake.mode,PHASE:.status.phase,DOORMAN:.status.conditions[?(@.type=="WakeOnRequest")].reason'NAME WAKE PHASE DOORMANapi Request Paused DoormanRoutedweb Ask Paused DoormanRoutedAny other reason under DOORMAN says why requests can’t reach the doorman (When a workload isn’t routed).
-
Ask for it as a robot would.
Terminal window curl -i https://pr-42.preview.example.com/It gets a
503saying how to wake it (what robots get), and the preview stays paused. -
Open it in a browser, and click Wake it.
The waking-up page carries on until a pod is Ready, then the page you opened loads. curl woke nothing.
A preview of more than one workload
Section titled “A preview of more than one workload”Every workload in the namespace asks, which suits what people open in a browser. An API the frontend calls wakes with it when it’s a dependency of the frontend. So does a database or a queue worker.
Napwake learns that from the frontend’s environment, such as an API_URL naming the API’s Service. Or declare it with napwake.dev/depends-on.
One click then wakes the whole preview, and its dependencies stay up as long as the frontend does. To see what wakes with a workload:
kubectl napwake deps web -n preview-42preview-42/web (Deployment, Paused)Depends on: preview-42/api learned from API_URL PausedDepended on by: nothingLearned links come from the dependent's environment or its requests; napwake.dev/ignore-dependencies drops one.A workload that’s nobody’s dependency is woken only by tooling while it asks. Its callers are other workloads, which are turned away. A database’s protocol isn’t HTTP, so its connections are closed.
Have it wake on any request instead, with the annotation on the workload, which wins over the namespace’s. No robot reaches a Service that no Ingress exposes, so only its real callers wake it.
apiVersion: apps/v1kind: Deploymentmetadata: name: api annotations: napwake.dev/wake: "request"See When to keep waking on request.
What people see
Section titled “What people see”Someone opens the preview, say https://pr-42.preview.example.com/orders/7, and gets the asleep page.
| The asleep page | |
|---|---|
| Says | The preview is asleep, with a Wake it button |
| Is sent as | 200 OK, with Cache-Control: no-store, X-Robots-Tag: noindex and Referrer-Policy: same-origin |
| Runs | Only its own style and script. No other site can show it in a frame |
| Names | No namespace, workload or Service |
| Reloads | Every minute, with a fresh token, so a page left open doesn’t go stale |
| Wakes | Nothing. Only the click does |
The button posts back to the same address, with a token the doorman signed for this preview and this pause. It’s good for 10 minutes.
A current browser says the click came from the page, with Sec-Fetch-Site over HTTPS, or its Origin over plain HTTP. The preview then wakes, with what it depends on, and a WokenByRequest event says a person asked.
Next comes the waking-up page. It says the preview is starting, and for how long, and reloads every 3 seconds. Once a pod is Ready, the next reload is the page they opened, /orders/7.
| When | What happens |
|---|---|
| Others open it after the click | The waking-up page, then the app, without clicking |
| A page left open whose reloads failed, as after a laptop slept | Its token expired. The page says so, and offers the button again |
| A browser from before about 2020 | It may not say where the click came from, so nothing wakes. The page says so, and how else to wake it |
| The app’s own form, such as its login, posted after the preview paused | The asleep page. Nothing wakes until someone clicks |
| Once it’s awake | It pauses again after idleAfter with no activity, an hour by default |
The other ways to wake it are kubectl napwake wake, a wake request, or a current browser. The Wake it button has the token, and what a click from another site gets.
What robots and API clients see
Section titled “What robots and API clients see”Anything but a person’s browser opening a page, or posting a small form, gets a 503. That’s curl, scripts and API clients, and a page’s own fetch. Crawlers and link previewers such as Googlebot, headless browsers and scanners get it too.
It says the preview is asleep and how to wake it, and nothing wakes:
curl -i https://pr-42.preview.example.com/HTTP/1.1 503 Service UnavailableContent-Type: text/plain; charset=utf-8Cache-Control: no-storeRetry-After: 300X-Robots-Tag: noindex
This environment is asleep. It was paused because it looked unused, to save money.Requests don't wake it. To wake it, do one of these, then try again:- Open it in a browser and press Wake it.- Run kubectl napwake wake NAME -n NAMESPACE.- From tooling, set the annotation napwake.dev/wake-requested to a new value on its ManagedWorkload.More at https://docs.napwake.dev/guides/preview-environments/| The caller | What it gets |
|---|---|
A client that sends Accept: application/json |
The same, in JSON |
| A probe or health check the doorman knows | Answered as for any workload |
| A connection that isn’t HTTP, such as a database’s | Closed |
| Anyone, once someone has woken the preview | Served as usual |
See What browsers, robots and API clients see.
Waking it from tooling
Section titled “Waking it from tooling”Tooling sets napwake.dev/wake-requested on the preview’s ManagedWorkload to a new value, whatever its wake setting. Napwake acts on each value once, records it in status.lastWakeRequest, and answers in the WakeRequest condition (Wake now).
A ManagedWorkload from the label is named after its workload, such as web. Waking it wakes what it depends on too.
When a pull request is pushed
Section titled “When a pull request is pushed”A deploy that leaves the replicas to Napwake, as each tab above sets up, doesn’t wake a paused preview. Its new pods start at the next wake.
One that sets replicas scales the preview up, which wakes it as a scale-up from outside. Under Flux, that’s a GitOpsConflict, and the next pause waits an hour (GitOps tools).
To have it up for whoever reviews each push, wake it from the workflow that deploys it, and wait until it’s Running. In GitHub Actions:
name: Previewon: pull_request:
jobs: preview: runs-on: ubuntu-latest steps: # ...the steps that deploy the preview and give kubectl access to the cluster - name: Wake the preview env: NS: preview-${{ github.event.pull_request.number }} TOKEN: ${{ github.run_id }}-${{ github.run_attempt }} run: | kubectl wait managedworkload/web -n "$NS" --for=create --timeout=1m kubectl annotate managedworkload web -n "$NS" --overwrite napwake.dev/wake-requested="$TOKEN" kubectl wait managedworkload web -n "$NS" --for=jsonpath='{.status.lastWakeRequest}'="$TOKEN" --timeout=10m answer=$(kubectl get managedworkload web -n "$NS" \ -o jsonpath='{.status.conditions[?(@.type=="WakeRequest")].status} {.status.conditions[?(@.type=="WakeRequest")].message}') case "$answer" in True*) ;; *) echo "::error::${answer#* }"; exit 1 ;; esac kubectl wait managedworkload web -n "$NS" --for=jsonpath='{.status.phase}'=Running --timeout=10m| Part | Why |
|---|---|
The first kubectl wait |
On the first push, Napwake makes the ManagedWorkload moments after the deploy. --for=create needs kubectl 1.31 or later |
| A request set before Napwake takes it on | Answered already awake, as for any awake preview |
| The token | The run’s ID and attempt, so each run asks afresh, a re-run included |
| The second wait | Until Napwake takes the request up. One made as the preview pauses is answered at once, and calls the pause off. One made as it wakes is answered once the wake finishes (Wake now). So this wait can last as long as the start, and has the same 10 minutes as the last |
| When Napwake won’t wake it | The step fails with the reason, such as not woken at 2026-10-23 21:01 UTC: it's scaled to zero outside Napwake, which leaves it off until its replicas are set above zero |
| The last wait | Until the preview is Running. Its dependencies wake with it. To wait for them too, add a kubectl wait for each |
From a terminal
Section titled “From a terminal”kubectl napwake wake web -n preview-42waking preview-42/web...preview-42/web is Running after 2s--wait, the default, waits for Napwake’s answer, then until it’s Running. Both fit in --timeout, 5 minutes by default. A request made as the preview pauses calls the pause off. One made as it wakes is answered once the wake finishes.
That’s inside the same 5 minutes. For a slower start, give it more, such as --timeout 10m (Wake a workload).
From a portal or a chat bot
Section titled “From a portal or a chat bot”Anything that talks to the Kubernetes API can ask. A portal that has just made the environment gets 404 Not Found at first. Napwake makes its ManagedWorkload a moment after the workload, so the portal retries until it exists.
With curl, a PATCH sets the annotation, and prints nothing unless it fails. $APISERVER is the API server’s address, ca.crt its CA certificate, and $KUBE_TOKEN a token for the bot’s ServiceAccount:
NS=preview-42REQUEST=chat-$(date -u +%Y%m%dT%H%M%SZ)curl --fail-with-body -sS -o /dev/null --cacert ca.crt -X PATCH \ -H "Authorization: Bearer $KUBE_TOKEN" \ -H 'Content-Type: application/merge-patch+json' \ -d '{"metadata":{"annotations":{"napwake.dev/wake-requested":"'"$REQUEST"'"}}}' \ "$APISERVER/apis/napwake.dev/v1alpha1/namespaces/$NS/managedworkloads/web"Then read the ManagedWorkload every second or two until Napwake has answered. A request made as the preview pauses is answered at once, and calls the pause off. One made as it wakes is answered once the wake finishes. So keep reading as long as it takes to start:
curl --fail-with-body -sS --cacert ca.crt -H "Authorization: Bearer $KUBE_TOKEN" \ "$APISERVER/apis/napwake.dev/v1alpha1/namespaces/$NS/managedworkloads/web" | jq -r --arg request "$REQUEST" 'select(.status.lastWakeRequest == $request) | .status.conditions[] | select(.type == "WakeRequest") | "\(.status) \(.message)"'True woken as requested at 2026-10-23 21:03 UTCIt prints nothing until then. False gives the reason in the message. Once it’s True, .status.phase says when the preview is Running.
The access tooling needs
Section titled “The access tooling needs”A Role in the preview’s namespace, bound to your tooling’s ServiceAccount, such as one in a ci namespace:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: preview-waker namespace: preview-42rules: - apiGroups: [napwake.dev] resources: [managedworkloads] verbs: [get, list, watch, patch]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: preview-waker namespace: preview-42roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: preview-wakersubjects: - kind: ServiceAccount name: preview-waker namespace: ci| Tool | Verbs on managedworkloads |
|---|---|
kubectl annotate |
get, patch |
kubectl wait |
get, list, watch. It lists the ManagedWorkload by name before watching it. Without list, it waits out its timeout, even once the condition is met |
kubectl napwake wake |
get, list, patch |
| An HTTP client, such as a portal’s | patch to ask, and get or watch to read the answer |
People with the built-in edit or admin role in the namespace have all of these once Napwake is installed.
What else wakes it
Section titled “What else wakes it”| Also wakes it | What to know |
|---|---|
| An activity annotation, from CI or a script | Wakes it, as with request |
| A dependent waking | Wakes it, since a wake takes its dependencies with it |
| Something else scaling it up | It’s up, with a ScaledUp event naming what did it |
| A Prometheus activity query | Wakes it when it sees activity |
A query that counts ingress requests counts robots’ requests too, so it would let them wake it. For a public environment, count something else, or filter bots out.
Up before people come
Section titled “Up before people come”With ask, only people wake a paused preview. So behind the doorman, the forecast learns from every hour it’s paused. An hour is busy if someone woke it, with a click, a wake request or an activity annotation.
Otherwise it’s quiet. If someone had needed it, they’d have woken it.
Some hours are learned only if someone woke it. Those are hours the doorman isn’t routing it, or its activity queries can’t be evaluated. So an outage can’t teach the forecast quiet hours (What an hour records).
Turn on autoResume. Once the forecast is confident, it wakes the preview 15 minutes before the hour people usually come.
apiVersion: v1kind: Namespacemetadata: name: preview-42 labels: napwake.dev/managed: "true" annotations: napwake.dev/wake: "ask" napwake.dev/auto-resume: "true"| What to expect | |
|---|---|
| A weekday pattern | It acts after 8 days, and is fully active after 14 (How long it takes) |
| From then | Someone who clicks at about 09:05 on weekdays finds it up from 08:45. Weekends stay asleep |
| A new preview | It starts from nothing, with no history to learn from. One for a pull request merged within a week is gone before the forecast acts |
| Where it pays off | Previews that last, such as one per team or long-lived branch, or a demo environment |
| Wakes from tooling | They count as people’s. A workflow that wakes it on every push teaches the hours your team pushes in |
| Pausing early | Never, with ask. Someone who came while the forecast expected quiet would find it asleep (Pausing early) |
The forecast says what it learns from:
kubectl get managedworkload web -n preview-42 -o jsonpath='{.status.prediction.message}'learning: it has seen 0 of the 24 hours of the day, and can't act until it has seen them all; while it's paused, it learns from people waking it (a click on its asleep page, a wake request or an activity annotation)What it turned away
Section titled “What it turned away”The doorman counts the requests it turns away during each pause. That’s the 503s and closed connections, not people shown the asleep page, nor probes and health checks. It’s a count only, with nothing of the requests themselves.
It’s in the ManagedWorkload’s status, brought up to date about once a minute. It covers the current pause, or the last one once the preview is awake.
kubectl get managedworkload web -n preview-42 -o jsonpath='{.status.turnedAway}'{"pausedAt":"2026-10-23T20:41:44Z","requests":3}kubectl napwake status lists the workloads that turned requests away:
Turned away while asleep, by a doorman that asks before waking: something automated may call these. preview-42/web 3 requests during the pause from 6m agoOn a public URL, crawlers and scanners account for some. That’s what asking is for.
More than you’d expect can mean something of yours calls the preview. A person is never turned away, so find out what it is.
| It’s | Then |
|---|---|
| An uptime monitor or health checker | Add its User-Agent to the Helm value doorman.healthCheckUserAgents. It’s then answered as a health check, and not counted |
| Something that needs the preview awake, such as a test suite or a webhook | Have it set a wake request first |
napwake_doorman_wakes_total{result="turned_away"} counts them as they happen (Metrics, What it turns away).
What it can’t do
Section titled “What it can’t do”| It can’t | Why |
|---|---|
| Stop a robot that clicks | One that loads the page and clicks wakes the preview as a person does. That’s at most once a minute, whichever doorman pod it reaches, and the preview then stays up for idleAfter. It’s rare, and costs a wake |
| Keep a preview private | Asking keeps robots from waking it, not people from using it. Anyone with its URL can click and see it. For anything private, use a VPN, single sign-on at the ingress, or an allowlist, and keep request |