GitHub Action
Deploy from GitHub Actions with a kubeconfig — no cloud credentials, no provisioning.
Deploy on every push, from your own CI, to your own cluster.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: getknext-dev/knext/packages/kn-next-action@v1
with:
kubeconfig: ${{ secrets.KUBECONFIG_B64 }}
namespace: acme
registry: registry.example.com/acme
registry-token: ${{ secrets.REGISTRY_TOKEN }}
skip-upload: 'true'That is the whole thing. Push to main, and ACME redeploys.
You don't need a Bun setup step of your own: this action installs the Bun toolchain it needs to
compile your app before it deploys, even on a stock ubuntu-latest runner. Everything else — the
Node.js and Docker steps above — is what your own build needs, not what the action needs.
What this action deliberately does not do
It never asks for cloud-account credentials, and it does not create clusters.
That is a design decision, not an omission. Creating managed Kubernetes requires permissions that
cannot be scoped down. On AWS, eksctl's own documented minimum grants iam:CreateRole +
iam:AttachRolePolicy + iam:PassRole — which together are the textbook privilege-escalation
primitive, with no conditions attached to stop it. A credential that can create a cluster can
generally do anything in the account, and the bill arrives either way.
A kubeconfig is different in kind. It is scoped to one cluster, and can be scoped further to one namespace. Leaked, it costs you that cluster — not your cloud account.
So: you bring a cluster, this action does everything after it.
Two shapes, and which to pick
Credential-free — skip-upload: 'true'
Needs only a kubeconfig and registry auth. Nothing cloud-specific.
You lose static-asset offload to object storage and the post-deploy asset GC. The app still builds, deploys, serves and scales to zero. Start here — it is the shortest path to a working pipeline, and you can add uploads later without changing anything else.
Full parity — assets uploaded
knext shells out to your storage provider's CLI, so authenticate it before the deploy step, using whichever action your provider already publishes:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.GCP_SA }}
- uses: getknext-dev/knext/packages/kn-next-action@v1
with:
kubeconfig: ${{ secrets.KUBECONFIG_B64 }}
namespace: acme
registry: registry.example.com/acme
registry-token: ${{ secrets.REGISTRY_TOKEN }}
bucket: acme-assetsThe equivalents are aws-actions/configure-aws-credentials, azure/login, or mc alias set for
MinIO. In every case the action authenticates to a bucket you already own — it never creates one.
Prefer workload identity / OIDC over long-lived keys where your provider offers it. The knext action does not care which you use; it only needs the provider CLI to be authenticated when it runs.
Getting the kubeconfig
Do not use your personal admin kubeconfig. The action refuses one — see below — so the fastest path is also the correct one:
knext init-ci --namespace my-appThat writes two files and touches nothing in your cluster:
.github/workflows/knext-deploy.yml— the workflow, with every secret it needs documented at the topknext-ci-rbac.yaml— a ServiceAccount, a Role and a RoleBinding
Read the manifest, then apply it yourself:
kubectl apply -f knext-ci-rbac.yamlBuild a kubeconfig from the knext-deployer ServiceAccount's token. A token Secret gives CI a
credential that does not expire the way kubectl create token output does. The manifest holds no
token; Kubernetes fills it in:
kubectl apply -f - <<'YAML'
apiVersion: v1
kind: Secret
metadata:
name: knext-deployer-token
namespace: my-app
annotations:
kubernetes.io/service-account.name: knext-deployer
type: kubernetes.io/service-account-token
YAML
umask 077
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl -n my-app get secret knext-deployer-token -o jsonpath='{.data.ca\.crt}')
TOKEN=$(kubectl -n my-app get secret knext-deployer-token -o jsonpath='{.data.token}' | base64 -d)
cat > ci-kubeconfig.yaml <<YAML
apiVersion: v1
kind: Config
clusters: [{name: cluster, cluster: {server: $SERVER, certificate-authority-data: $CA}}]
users: [{name: knext-deployer, user: {token: $TOKEN}}]
contexts: [{name: ci, context: {cluster: cluster, user: knext-deployer, namespace: my-app}}]
current-context: ci
YAMLCheck that it is the scoped one: KUBECONFIG=ci-kubeconfig.yaml kubectl auth can-i get secrets -n my-app
must answer no. Then base64-encode it, store it as an encrypted secret under
Settings → Secrets and variables → Actions, and delete the file:
base64 -w0 < ci-kubeconfig.yaml # macOS: base64 -i ci-kubeconfig.yaml
rm ci-kubeconfig.yamlStore it on an environment limited to your default branch, not as a repository secret. Then a pull request that edits the workflow still cannot read it.
The action loads its credential check from your app's installed @getknext/core, so install your
dependencies (npm ci) in a step before it.
What the credential can actually do
This is the whole Role. There is no second page:
rules:
- apiGroups:
- apps.kn-next.dev
resources:
- nextapps
verbs:
- get
- list
- create
- patch
- updateNo delete, so a leaked token cannot remove your app. No secrets, so it cannot read your database
password. No Deployments, Services or Pods at all.
That is possible because deploying is writing one object. The operator running in your cluster watches for it and does everything else — Knative configuration, autoscaling, networking. CI never needs to touch any of it.
An over-broad kubeconfig is refused, not merely discouraged. On startup the action asks your cluster what the credential can do. If it can read Secrets, act on Deployments, or carries a wildcard rule, the job fails and prints the Role above instead of deploying. Asking for less than you are offered is the point — a CI secret has a wider blast radius than a laptop.
A kubeconfig that needs a cloud CLI on the runner is refused outright. If it authenticates via
an exec: or auth-provider: entry — the shape aws eks get-token, gke-gcloud-auth-plugin,
oci ce cluster generate-token or kubelogin use — the job fails immediately, before any cluster
call, with:
This kubeconfig needs cloud-account credentials on the runner. Use the knext-deployer ServiceAccount token.
This runs as its own step, and skip-credential-preflight does not turn it off: it reads only
the kubeconfig file, so there is no cluster limitation for the escape hatch to work around.
knext holds no cloud-account credentials, ever. It accepts credentials that authenticate without
running anything on the runner: a bearer token (the kind knext init-ci mints) or a client
certificate and key. The same check backs knext doctor --ci-kubeconfig <path>, so you can check a
kubeconfig FILE before it ever reaches CI. If the file is not valid YAML, the error gives at most
the line number of the problem — never the file's contents, because those contents are a credential
and the message lands in your CI log.
Some clusters cannot fully answer "what can this credential do?" — common on webhook-authorized clusters such as OKE or GKE, where IAM answers authorization instead of Kubernetes RBAC. The action does not fail closed on that incompleteness alone (doing so would refuse the scoped credential this check exists to allow, not just an over-broad one). Instead it asks the cluster about specific permissions, one at a time, and refuses if ANY of them is allowed:
- every permission the Role above grants, asked in a different namespace — an allow there means the grant is not limited to your namespace;
- every action on
nextappsthe Role does not grant (such asdelete); - a fixed list of escalation paths: exec into or create Pods, create Jobs, CronJobs, StatefulSets or DaemonSets (each of which runs Pods without asking for Pods directly), patch Deployments (in your namespace or cluster-wide) or Knative Services directly, create ServiceAccounts or mint their tokens, read, list or watch Secrets, escalate or bind Roles and ClusterRoles, create RoleBindings or ClusterRoleBindings, impersonate users, groups or ServiceAccounts, proxy to nodes, and any wildcard.
It also refuses if any of those questions cannot be answered — an error, or a reply with no clear yes/no. A check that passes when it cannot see is not a check. On these clusters this is a spot-check, not a proof: a permission outside that list is not asked about. The Role above, applied as-is, passes every question.
skip-credential-preflight: true exists for the case where your cluster implements neither review
at all. It turns the permission checks off rather than satisfying them; the cloud-credential
refusal above still runs.
Inputs
| Input | Required | Default | Notes |
|---|---|---|---|
kubeconfig | yes | — | Base64-encoded, from the scoped ServiceAccount |
namespace | yes | — | No default: it is what bounds the credential's blast radius |
registry | yes | — | Registry host and namespace, e.g. ghcr.io/acme. Your app's name is appended, so the image is pushed to ghcr.io/acme/<name> |
registry-token | no | — | On GHCR, github.token suffices |
registry-username | no | github.actor | |
tag | no | github.sha | Image tag. The commit SHA traces an image to its commit. Letters, digits, _, . and - only (up to 128 characters, not starting with . or -); anything else is refused. A branch name such as feature/login is not a valid tag, so replace the / or use the SHA |
bucket | no | from knext.config.ts | Object-storage bucket for static assets. Lowercase letters, digits, ., _ and - (3 to 222 characters, starting and ending with a letter or digit); anything else is refused |
skip-upload | no | false | Skip the static-asset upload. Needs only a kubeconfig and registry auth |
skip-build | no | false | Skip next build, for when a previous job already produced the output |
auth | no | kubeconfig | Reserved for short-lived cloud credentials later |
dry-run | no | false | Prints the NextApp resource, applies nothing |
working-directory | no | . | Where knext.config.ts lives |
doctor | no | true | Read-only preflight; fails fast on an unready cluster |
skip-credential-preflight | no | false | Turns off the credential check. Read the callout above first |
cli-version | no | an exact release (see the action) | The version of the published @getknext/core CLI the action runs. It must be an exact version such as 1.3.1; ranges and tags like latest are refused. The action never runs an unpinned latest, because this step holds your kubeconfig and registry token |
cli | no | empty | A command to run instead of the published package, split on whitespace. Leave it empty — it is only there so this action can be tested against a locally built CLI. When set, cli-version is ignored |
The action sets no outputs. The deployed URL is reported by the cluster — read it with
kubectl get ksvc -n <namespace>, or knext status. An output that was empty on every dry run
and unreliable otherwise would be worse than none.
Preview deploys on pull requests
dry-run prints the NextApp custom resource that would be applied, without touching the cluster:
on: pull_request
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- uses: getknext-dev/knext/packages/kn-next-action@v1
with:
kubeconfig: ${{ secrets.KUBECONFIG_B64 }}
namespace: acme
registry: registry.example.com/acme
dry-run: 'true'This makes the diff reviewable before it lands — the same instinct as terraform plan.
Passing values from pull requests
Every input reaches the action's scripts as an environment variable, never pasted into script text, so
a value like a branch name cannot run as a command. tag and bucket are also checked against the
shapes listed above and refused if they do not fit, with an error that names the rule and does not
repeat the value. Your own workflow is still yours to protect: prefer github.sha for the tag, and
do not build the input from text an outside contributor controls without checking it first.
Why doctor runs first
By default the action runs the read-only knext doctor preflight and fails the job if the cluster
is not ready. It checks that the CRD exists, the operator is Ready, Knative Serving is installed, and
the ingress class matches the controller actually serving traffic.
These failures are much cheaper to read here than halfway through a container build. Set
doctor: 'false' to skip it, though there is rarely a good reason to.
Related
- Tutorial: deploy ACME — the manual path this automates
- CLI reference — every verb and flag the action wraps
- Upgrading — why the operator is upgraded before the CLI
Migrate from Vercel
Move a Next.js app from Vercel to your own Kubernetes cluster with knext. A feature map with honest gaps, a step-by-step migration, and a cost worksheet you fill in with your own numbers.
GitLab CI/CD
Deploy from GitLab CI/CD with a kubeconfig — no cloud credentials, no provisioning.