Deploying from a CI/CD Pipeline
Once an application is past the prototype stage, you want deployments to happen from your CI/CD pipeline, not from a laptop: a tag or release triggers a workflow, the workflow validates the code, and the pipeline tells your Harper cluster to deploy it. This guide uses GitHub Actions and GitHub's hosting (repositories and the GitHub Packages npm registry). On GitHub Actions, the Harper CLI authenticates with the runner's own identity token, so the workflow holds no Harper credential at all. Other CI systems call the deploy_component operation directly, which is a single HTTP call — see Other CI systems.
There are two ways to deliver an application to Harper, and choosing the right one is most of the battle:
- Deploy from a git tag — Harper installs your application directly from a tagged commit in your repository. Best for applications that run from source.
- Deploy from a package registry — CI builds and publishes an artifact to a private npm registry; Harper installs that artifact by version. Best for applications that need a build step.
Both work with private sources: deploy_component accepts a credentials array that names a credential in Harper's encrypted secrets store.
What You Will Learn
- How to choose between git-tag deploys and registry-artifact deploys
- How to let a GitHub Actions workflow deploy with no stored Harper credential, using an OIDC trust policy and a deploy-only role
- How to give Harper a durable credential for a private source — and why it must not be the workflow's ephemeral
GITHUB_TOKEN - A GitHub Actions workflow that deploys a tagged release straight from a repository
- A GitHub Actions workflow that builds, publishes to GitHub Packages, and deploys the published artifact
- How to wait until every node has taken the release, and how to go back to a previous one
- How to deploy isolated applications
- How to give each pull request its own preview, on its own branch of the data, with one setup command and the
HarperFast/deploy-actionGitHub Action
Prerequisites
- A Harper Fabric cluster, or a self-managed Harper cluster, running v5.3.0 or later, which added OIDC trust policies. Deploying from a private source also needs secret custody, which Fabric provides and the Pro secrets component provides on a self-managed cluster.
super_useraccess to the cluster from your own machine, to create the deploy user and the trust policy once. The pipeline never holds it.- The Harper CLI on your machine:
npm install -g harper. - A GitHub repository containing a working Harper application (Create your First Application if you don't have one).
- Familiarity with GitHub Actions basics (workflows, environments, variables).
Choosing a delivery model
Deploy from a git tag when your application runs from source. Most Harper applications do: a config.yaml, a schema.graphql, resource classes in JavaScript (or TypeScript that Harper runs directly via type stripping), and npm dependencies that install cleanly. For these, the repository is the artifact. A git-tag deploy has no publish step to maintain — you tag a release, CI runs harper deploy by_ref=true, and every node installs exactly that commit. The deployment record and the component config both name the commit, so the deployed version stays auditable.
Deploy from a registry when your application needs a build. If CI must produce output that isn't in the repository — a bundling step, compiled assets, code generation, native compilation — publish the built package to a registry and deploy that. This is better than making Harper build it for two reasons:
- The artifact you tested is the artifact you run. CI builds once, tests the result, publishes it. Every Harper node installs that identical, immutable artifact instead of re-running your build independently.
- No build scripts run on your database nodes. When Harper installs a credentialed git reference, npm's pack step runs with
--ignore-scriptsby default, precisely so repository code can't run during install with the deploy credential in reach. A repository that requires aprepare/build script to be runnable forces you to setinstall_allow_scripts: true, which allows that script — and your dependencies' install scripts — to execute on the node during deployment. A registry artifact needs none of that: it's already built.
A second constraint pushes the same direction: a git-reference deploy authenticates only the top-level repository. If your application has private git-hosted dependencies (a package.json dependency pointing at another private repo), their installation is not credentialed and will fail. Private registry dependencies work fine — registry credentials apply to the whole install. If your dependency graph reaches into private git repos, publish those dependencies (or the whole app) to a private registry instead.
| Your application... | Deploy from |
|---|---|
| Runs from source (schema, resources, npm dependencies) | Git tag |
| Needs a build step (bundling, codegen, compilation) | Private registry |
| Has private git-hosted dependencies | Private registry |
Let the workflow authenticate with OIDC
A GitHub Actions job that declares id-token: write can ask GitHub for an identity token that says which repository, workflow, and environment it is running for. The Harper CLI trades that token for a one-hour operation token, if it matches a trust policy you stored on the cluster. Nothing durable lives in your CI provider, and there is no 30-day refresh token to rotate. See Workload identity (OIDC) for how the CLI does this, and add_oidc_trust for every rule a policy can carry.
Nothing in the workflow names the policy. Once it exists, every harper command in the deploy job — harper deploy, harper get_job, anything else — authenticates through it by itself, and may do what the policy's user's role allows, as long as the job has id-token: write, HARPER_CLI_TARGET is the policy's audience, the run matches the policy's claims, and no other credential is configured. Using it in a workflow lists those conditions.
From v5.4.0, for a workflow that deploys when changes merge to a branch, harper deploy setup=true provider=github-actions creates a role, a user and a policy
pinned to that branch, and sets the HARPER_CLI_TARGET variable, in one command. Its role allows the same operations
as the role below. This guide's workflows deploy on version tags, which a branch pin cannot match, so they use the policy
below, written by hand.
You set this up once, from your own machine, logged in as a super_user:
harper login https://my-cluster.example.com:9925
Create a deploy-only user
The user a trust policy names is the privilege boundary: a matching run gets that user's role. A pipeline does not need super_user. Give it a role that lists only the operations the workflows below call:
harper add_role role=ci_deploy permission='{"operations":["deploy_component","get_job","get_deployment","registration_info"]}'
harper add_user username=ci-deploy role=ci_deploy active=true password="$(openssl rand -base64 32)"
deploy_componentis the deploy itself.get_deploymentreads the deployment record, whichharper deploy restart=rollingfollows on v5.4.0 and later to wait for every node. Without it that wait fails with403.get_jobis what the wait step polls when the deploy did not wait itself. The jobs table listsget_jobas open to any role, but anoperationsallowlist refuses every operation it doesn't list, so without it the wait step fails with403.registration_inforeturns only Harper's version, whichharper deploychecks before it uploads. Without it the deploy still works, but the cluster logs a refused operation on every run.
restart_service is not needed: the rolling restart's job is started by Harper itself, not by the caller. The password is required to create the user, but a GitHub Actions pipeline never uses it, since it authenticates through the trust policy, so this command discards it. Other CI systems shows how to set one you keep.
Deploying is still administrative authority, because the deployed component runs inside the Harper process. And a deploy that passes a literal registry or git token in credentials needs super_user, because Harper seals that token into the secrets store. So this role deploys private sources through a secret reference, which the next section sets up.
Add a trust policy
The workflows below run when you push a version tag, so the policy can't pin workflow_ref: the tag is part of it, and it isn't known when you write the policy. Pin the repository and the workflow file instead, and gate on a GitHub environment:
harper add_oidc_trust \
id=my-app-release \
issuer=https://token.actions.githubusercontent.com \
audience=https://my-cluster.example.com:9925/ \
user=ci-deploy \
claims='{"repository_id":"67890","workflow_path":"my-org/my-app/.github/workflows/deploy.yml","environment":"production"}'
repository_idpins the repository. It is immutable, so it survives a rename. Look it up withgh api repos/my-org/my-app --jq .id.workflow_pathpins the workflow file. Harper derives it from the token'sworkflow_refby removing the@<ref>suffix, so it is<owner>/<repo>/<path>.environmentis the ref gate. Without one, anyone who can push a branch could run the pinned workflow on it and mint a token. The gate is only as strong as the environment's rules, so in the repository's Settings → Environments → production, set Deployment branches and tags to thev*tag pattern, and add required reviewers if a release needs approval. Anyone who can push av*tag can still deploy, so restrict who can create those tags with a tag ruleset.audiencemust be the exact string the CLI asks for: the target URL with its port and a trailing slash.
A workflow that deploys on a push to main can pin workflow_ref (my-org/my-app/.github/workflows/deploy.yml@refs/heads/main) instead, which gates the ref by itself. Policy specificity for GitHub Actions explains why each of these pins is required, and why ref_type: tag is not accepted as a gate.
Finally, store the target as a GitHub Actions variable, not a secret, since it isn't sensitive:
gh variable set HARPER_CLI_TARGET --repo my-org/my-app --body https://my-cluster.example.com:9925/
Give Harper a durable credential for a private source
Skip this section if your repository is public, or your package is on a public registry.
Harper needs to authenticate to GitHub, and not only at the moment you deploy. Every node installs the release itself, and Harper keeps the credential (encrypted, in the secrets store) with the component's configuration, so that later installs — a new node joining the cluster, a reinstall after a restore — can fetch the package again without you re-supplying auth.
That has one practical consequence: use a durable token, not your workflow's ephemeral GITHUB_TOKEN. The automatic GITHUB_TOKEN in a GitHub Actions run expires when the job ends. It's fine for things that happen inside the job (like publishing a package), but an install that happens after the workflow finishes would fail authentication. Instead, create a token whose lifetime matches how long nodes may need to fetch this component:
- For a private repository (git-tag deploys): a GitHub fine-grained personal access token with Contents: read-only permission, scoped to just the application repository. An organization can attach these to a machine account so they aren't tied to an individual.
- For GitHub Packages (registry deploys): a token with the
read:packagesscope.
Store it in Harper once, from your machine, with harper deploy setup=true. It prompts for the token, encrypts it locally with the cluster's public key, grants it to the component, and prints the credentials reference to use — the secret's name, never the token. For a private repository:
harper deploy setup=true provider=github project=my-app
For GitHub Packages:
harper deploy setup=true provider=npm project=my-app registry=https://npm.pkg.github.com scope=@my-org
The name is derived from the component and the host, so for my-app it is deploy.my-app.git.github.com for the repository, and deploy.my-app.npm.pkg.github.com for GitHub Packages. The workflows below reference it by that name, so the token never appears in your pipeline. Harper Studio also provides a UI for creating, granting, and rotating secrets, and set_secret does the same from the Operations API.
Path A: deploy a tagged release from your repository
The flow: push a semver tag (v1.2.3) → the workflow runs harper deploy by_ref=true → every node clones that commit and installs it.
# .github/workflows/deploy.yml
name: Deploy to Harper
on:
push:
tags: ['v*']
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 26
- run: npm install --global harper@^5.3
- name: Deploy the tagged commit
run: harper deploy project=my-app by_ref=true restart=rolling json=true > deploy.json
- name: Wait for every node to take the release
run: |
# From v5.4.0 a certified rolling deploy waited for every node itself.
[ "$(jq -r '.rollout.status // empty' deploy.json)" != success ] || exit 0
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || { echo "The rolling deploy returned no restartJobId"; exit 1; }
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
harper get_job id="$JOB_ID" json=true > job.json || continue
case $(jq -r '.[0].status // "MISSING"' job.json) in
COMPLETE) exit 0 ;;
ERROR) jq -r '.[0].message' job.json; exit 1 ;;
MISSING) LAST="was not on the node that answered; its outcome is unknown" ;;
*) LAST="was still running" ;;
esac
done
echo "After 60 polls, job $JOB_ID $LAST"
exit 1
For a private repository, add credential=true to the deploy command. It attaches the deploy.my-app.git.github.com reference you created with harper deploy setup=true.
On a v5.4.0 cluster, the HarperFast/deploy-action GitHub Action can replace the job's steps, the wait included, since a v5.4.0 harper deploy restart=rolling waits for every node itself:
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_CLI_TARGET }}
app: my-app
by_ref: true
It installs the CLI, deploys with restart=rolling unless you set restart, and authenticates with the job's OIDC token alone. A private repository also needs credential: true. The job still needs environment: production and id-token: write for the trust policy; it needs no checkout.
A few details worth noting:
- No secrets.
HARPER_CLI_TARGETis the only thing the workflow is given.id-token: writelets the CLI request an identity token, andenvironment: productionputs theenvironmentclaim the trust policy requires into it. Any other step in the job could request one too, so the job runs none of the repository's code. The CLI prints which policy authenticated it, and as whom, to stderr. persist-credentials: falsestopsactions/checkoutfrom saving the job'sGITHUB_TOKENfor every later step to read. From v6 it saves the token in a file under$RUNNER_TEMPrather than in.git/config, whichharper deployuploads with the checkout, but later steps can still read it.harper@^5.3pins the CLI's major version. 5.3 is the first CLI that authenticates with the identity token, and rerunning an old tag after a new major release still installs a 5.x CLI.by_ref=truedeploysgit+https://github.com/<owner>/<repo>.git#<sha>, where the SHA is the commit the tag points to (GITHUB_SHA). Every node clones that exact commit, even if the tag is moved later. Passref=<sha>, with the full commit SHA, to deploy a different commit. In a private repository, a tag resolves only if the checkout fetched it, asfetch-depth: 0does: the CLI looks up anything else withgit ls-remote, which has no credential oncepersist-credentials: falsekeeps the token out of.git/config.replicatedisn't needed. On Harper Fabric and Harper Pro, a deploy goes to every node in the cluster unless you passreplicated=false. Each node fetches and installs the package itself, resolving the credential from the replicated secrets store. Harper core on its own does not replicate.restart=rollingrestarts nodes one at a time, so they never all restart at once. Each node stays in rotation while it restarts.restart=truedeploys to every node at once; each node replaces its workers a few at a time while the others keep serving. Choose a restart mode compares the two. Per-PR previews userestart=true, which restarts only each preview's own worker. On Harper core alone, a rolling job that only restarts — before v5.4.0, or whencertificationisunavailable— fails withReplication not implemented, which fails the wait step.json=trueprints the deploy's result as JSON on stdout. Progress and authentication messages go to stderr, sodeploy.jsonholds only the result. A deploy that fails exits non-zero, which fails the step.- Waiting for every node Changed in: v5.4.0. With
restart=rollingon v5.4.0 and later, unless the deploy'scertificationisunavailable, the other nodes take the release in a job, one at a time, andharper deploywaits for them on the deployment record before it exits: the deploy step fails, naming each node that did not take the release, or passes once every node has. Its result then carriesrollout, and the wait step does nothing. Otherwise, as before v5.4.0,harper deployexits0once the node you called has taken the release and started the rolling job: every node has installed the release, and the job only restarts them; on v5.4.0 and later, a node that did not restart fails the job, and itsresult.replicatedsays which (see how a rolling restart reports each node). The wait step then polls the job'srestartJobIdwithget_jobuntil it endsCOMPLETEorERROR, and retries a poll that fails, such as one that reaches a node while it restarts. Eachharper get_jobauthenticates through the trust policy the same way. It gives up after 60 polls, about ten minutes. Nodes restart one after another, so on a cluster with many nodes raise the count. Withrestart=truethere is no job, and the deploy step itself fails when a node rejects the release, unless you passignore_replication_errors=true. - The job lives on one node. The deployment record that
harper deploywaits on replicates, so its wait works through a load-balanced cluster URL. Harper does not replicate jobs, so only the node that received the deploy knows the rolling job, and the wait step'sget_jobpolls have that limit. Any other node answersget_jobwith an empty list, which the wait step treats as not finished and polls again. So through a load-balanced cluster URL, at least one poll has to reach the node that received the deploy. Routing that keeps the runner on one node sends its polls where the deploy went, and routing that spreads requests across nodes reaches it within a few polls. Routing that sends the polls to a different node than the deploy never does: the step fails, saying the job was not on the node that answered and its outcome is unknown, even when the rollout succeeded. If your load balancer routes that way, use one node's own URL for both the deploy and the wait, and as the trust policy'saudience. - Canary certification Added in: v5.4.0. On v5.4.0 and later,
restart=trueandrestart=rollingload the release in a canary worker on each node before that node serves it, except in the cases that section lists, so a release that fails to load is rejected. Withrestart=rolling, the node you called certifies it before the deploy responds, and each other node certifies it as the job reaches it. - The token for a private repository is served to git from memory on each node — it is never written into a URL, a lockfile, or a file on disk.
If this repository needs a build step to be runnable, stop — don't reach for install_allow_scripts. Use Path B.
Path B: build in CI, publish to GitHub Packages, deploy the artifact
The flow: push a tag → CI builds and tests → npm publish to GitHub Packages → harper deploy installs the published version from the registry.
Your package.json needs a scoped name and a publishConfig pointing at GitHub Packages:
{
"name": "@my-org/my-app",
"version": "1.2.3",
"publishConfig": { "registry": "https://npm.pkg.github.com" },
"files": ["config.yaml", "schema.graphql", "dist/"]
}
Use files deliberately — it defines exactly what ships in the artifact. Then:
# .github/workflows/deploy.yml
name: Build, publish, and deploy to Harper
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 26
registry-url: https://npm.pkg.github.com
scope: '@my-org'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run build
- run: npm test
- name: Publish to GitHub Packages
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy:
needs: publish
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
steps:
- uses: actions/setup-node@v7
with:
node-version: 26
- run: npm install --global harper@^5.3
- name: Deploy the published version
run: |
harper deploy project=my-app package="@my-org/my-app@${GITHUB_REF_NAME#v}" \
credentials='[{"registry":"https://npm.pkg.github.com","scope":"@my-org","secret":"deploy.my-app.npm.pkg.github.com"}]' \
restart=rolling json=true > deploy.json
- name: Wait for every node to take the release
run: |
# From v5.4.0 a certified rolling deploy waited for every node itself.
[ "$(jq -r '.rollout.status // empty' deploy.json)" != success ] || exit 0
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || { echo "The rolling deploy returned no restartJobId"; exit 1; }
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
harper get_job id="$JOB_ID" json=true > job.json || continue
case $(jq -r '.[0].status // "MISSING"' job.json) in
COMPLETE) exit 0 ;;
ERROR) jq -r '.[0].message' job.json; exit 1 ;;
MISSING) LAST="was not on the node that answered; its outcome is unknown" ;;
*) LAST="was still running" ;;
esac
done
echo "After 60 polls, job $JOB_ID $LAST"
exit 1
The workflow has two jobs, so the job that can request an identity token runs none of your code:
publishinstalls, builds, tests and publishes, withcontents: readandpackages: writeonly. It can't request an identity token.deployruns oncepublishhas finished (needs: publish), and holds theproductionenvironment andid-token: write. It installs only the Harper CLI, then deploys the published version. The trust policy above still matches it: only this job names the environment, so only its token carries theenvironmentclaim the policy requires.
GitHub gives every step of a job with id-token: write the ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN variables. In a single job, any code the build runs, such as a compromised dependency's install script, could use them to request an identity token with that job's claims, and exchange it for the ci-deploy user's operation token. That user can deploy any component on the cluster, and a deployed component runs inside the Harper process. Two jobs narrow that risk, but don't remove it: the package deploy installs is still built from the same dependencies, so a compromised one can change what it contains.
If the production environment requires reviewers, publish publishes the package before anyone approves, since the approval gates only deploy.
Note the two credentials play different roles, matching their lifetimes:
- Publishing, and installing private
@my-orgdependencies withnpm ci, use the workflow's ephemeralGITHUB_TOKEN(withpackages: write, which includes read). Both happen entirely inside thepublishjob, so an ephemeral token is exactly right. A dependency published from another repository has to grant this one access first, under that package's Manage Actions access settings. - Installing uses the durable
read:packagestoken you stored in Harper asdeploy.my-app.npm.pkg.github.com— because installs can happen long after this workflow ends.
The deploy step installs the version the tag names, while npm publish publishes the version in package.json, so the two must agree: npm version 1.2.3 sets one and creates the other as v1.2.3.
scope: '@my-org' on setup-node sends only @my-org packages to GitHub Packages, so other dependencies still install from the public registry. The scope in credentials does the same on each Harper node, so scoped dependencies from the same registry resolve too.
Check the deployment record
deploy.json also carries a deployment_id. Harper records each deployment — phase transitions, per-node outcomes, and install output — in a deployment record you can read with the ci_deploy release role:
harper get_deployment deployment_id="$(jq -r .deployment_id deploy.json)" json=true
From v5.4.0, a certified rolling deploy's record follows its job: it says rolling_out until the job has visited every node, then success, or partial with error.nodes naming the nodes that did not take the release, and each node's outcome is under its peer_results entry's activation. Before v5.4.0, or when the deploy's certification is unavailable, only the node you called writes the record, so it can report success before the other nodes have restarted; wait on the job for that. The record also carries the deploy's source: through the trust policy, the run's repository, commit, workflow and run, attested by the exchange. list_deployments gives you the history of what was deployed and when, and can filter by commit with sha.
Run CI deployments in an isolated worker
Added in: v5.3.0An isolated application runs in a dedicated worker thread. You can use it for your main application or for additional copies deployed by CI.
Prepare routing before converting a live application. An isolated application stops answering on Harper's shared HTTP ports. On Fabric, add, verify, and bind its hostname as a custom domain, with a hostname used by no other application; the default Fabric hostname does not route to its dedicated worker. On a self-managed instance, configure a proxy that terminates TLS and forwards to that application's Unix socket. Sending the application's Host header to Harper's shared HTTP port does not reach it. See Reaching an isolated application.
Check the instance's isolation requirements: a nonzero threads.count, http.securePort, tls.unixDomainSockets: true, and a platform that supports Unix sockets. For a TLS certificate array, use the root tls_unixDomainSockets setting described under tls. Each isolated application adds a worker on top of the shared pool and takes one of the threads.maxIsolated dedicated workers, which default to 8, in the order startup admits them. One whose live release failed closed takes none. Budget memory and disk on every node: dedicated workers count when Harper divides its default heap limit, and each application can keep replaced releases under staging retention.
Then add isolated=true and the unique host to the package deploy in Path A or Path B; for example, Path A becomes:
harper deploy project=my-app by_ref=true \
isolated=true host=app.example.com restart=rolling json=true > deploy.json
Keep the workflow's wait step when using restart=rolling. For a single-node instance, use restart=true instead. Changing isolation makes canary certification unavailable on v5.4.0 and later; see Canary certification.
Turning isolation off also restarts the shared workers, as does turning it on for an application they had loaded, or, before v5.4.0, creating an isolated application. From v5.4.0, a new isolated application's first restart=true deploy starts only its own worker. Later redeploys that keep the application isolated restart only its worker, except an activation retry whose release is already live. A package redeploy that omits isolated preserves it; pass isolated=false to return the application to the shared pool.
Per-PR previews
Added in: v5.4.0A preview runs a pull request's code at its own URL, beside production, so a reviewer can try the change before it merges. PR 42, from the branch fix/cart, deploys as my-app-pr-42, an isolated application served at https://fix-cart.app.<cluster host>/. Each push to the PR updates it. Closing or merging the PR drops it, and the cluster drops it on its own seven days after its last deploy, so a missed close event leaves nothing behind for long. By default, each preview runs on its own branch of the cluster's databases, so what it writes stays out of production's data.
It takes one workflow file and one command, and stores no secret in GitHub:
- The workflow,
.github/workflows/preview.yaml, runs theHarperFast/deploy-actionGitHub Action onpull_request_target, from your default branch. When a PR opens or changes, the action deploys its head commit as the PR's preview and keeps one comment on the PR with the preview's URL. When the PR closes, it drops the preview. harper preview setupchecks that workflow and enables previews of the application on the cluster. It creates a user whose role can deploy and drop only that application's previews, and trust policies that let that one workflow file act as that user.
Read How previews stay safe before you turn previews on for a cluster that also serves production.
Harper Studio's Previews panel, not released yet, will turn previews of an application on and off and show its limits from the browser, using the same cluster-side setup as harper preview setup.
Before you start
- Every node runs v5.4.0 or later. On v5.3 has what differs before that.
- The cluster can run isolated applications: a nonzero
threads.count,http.securePort, andtls.unixDomainSockets: true. Each running preview takes a slot ofthreads.maxIsolated,8by default, on each node, and a worker thread with its memory. See Requirements and refusals. - Branched data needs RocksDB storage, and a module loader other than
native. On a cluster that cannot branch, or for previews that should use the cluster's own data, set previews up withdataMode=shared, and read Shared or branched data first. - Preview hosts reach the cluster. Every Colocated and Dedicated cluster on Harper Fabric serves
*.app.<cluster host>with no setup. A cluster created before preview hosts gains them one instance at a time, as each instance's certificate renews, within about seven weeks, and until every instance has renewed, a preview answers only some of the time. See Preview Hosts. On a self-managed cluster, point a wildcard DNS record and certificate for a preview domain at a proxy that routes each host to its application's socket, as in Reaching an isolated application, and pass that domain to setup withdomain=and to the action withdomain:. - On your machine: the Harper CLI,
super_useraccess to the cluster, and theghCLI signed in to an account that can manage the repository's Actions variables.
Add the preview workflow
In a checkout of the repository, on its default branch, add .github/workflows/preview.yaml, with your application's component name as app and your default branch under branches:
name: Preview
on:
pull_request_target:
types: [opened, reopened, synchronize, closed]
branches: [main]
permissions: {}
jobs:
deploy:
if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview
permissions:
id-token: write
pull-requests: write
deployments: write
concurrency:
group: harper-preview-${{ github.event.pull_request.number }}
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: my-app
preview: true
cleanup:
if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: write
deployments: write
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: my-app
preview: true
- It never checks out the PR. The action has the cluster fetch the PR's head commit itself, by reference, so no step of a job holding the identity token runs the PR's code. It authenticates with the job's OIDC token alone, against the
HARPER_PREVIEW_TARGETvariable setup sets, and refuses any other Harper credential in its environment. - Fork PRs are skipped. Each job runs only when the PR's head repository is this repository, and the action skips a fork's PR itself too.
- Only PRs into the default branch get a preview, and retargeting a PR is not one of the events the workflow runs on. A PR retargeted to the default branch gets its preview at its next push. One retargeted away keeps the preview it had, unchanged, and closing it does not drop it: it lasts until it expires, a sweep drops it, or you run
harper preview drop. - A deploy that waited goes ahead only while it is still current. The action deploys only while the PR is open at the commit the event named, since an approval on
previewcan hold the job for a while. If the PR closes while it deploys, it drops what it deployed. The concurrency group runs one deploy per PR at a time. - The PR shows the preview. The action keeps one comment on the PR with the preview's URL, updated in place, and records each preview as a GitHub Deployment in the
my-app-previewenvironment, kept apart from thepreviewenvironment that gates the job. Both need thepull-requests: writeanddeployments: writepermissions, and the deploy still counts if either fails. Turn them off withcomment: falseandgithub_deployment: false. - Cleanup runs in its own environment, with a token that can only drop, so it never waits for the
previewenvironment's reviewers. While a deploy of the same PR is still activating, the drop answers409, so the action retries it for about two minutes. A PR reopened before cleanup drops keeps its preview. One reopened in the moment between cleanup's check and its drop can lose the preview its reopen just deployed; push again, or rerun the deploy job, to bring it back.
The action's README lists every input. Keep the workflow's rules when you edit it later, since harper preview setup checks the file only when it runs: no checkout or fetch of the PR's code in a job with id-token: write, and the same-repository condition on every job that holds the token. The cluster still enforces the role's limits, whatever the workflow does.
Enable previews
From that checkout, logged in to the cluster as a super_user:
harper login https://my-cluster.example.com:9925
harper preview setup app=my-app
app is the application's component name: the project it deploys as. Setup checks the GitHub side, then enables previews on the cluster, then writes the GitHub side:
- It looks the repository up for its id and default branch.
- It reads
.github/workflows/preview.yamlfrom the checkout, and refuses a workflow that could give the preview identity to a fork or to a PR's code. If the file doesn't exist, it offers to write a starter workflow of its own. - It calls
enable_previewson the cluster, which creates:- the role and user
my-app-ci-preview, whose role can deploy, drop and read onlymy-app-pr-<n>previews, and run the version checkharpermakes before it deploys; - the trust policy
github-actions-my-app-preview, for jobs in thepreviewenvironment; - the trust policy
github-actions-my-app-preview-cleanup, for jobs in thepreview-cleanupenvironment, whose token can only drop.
- the role and user
- It sets the repository variable
HARPER_PREVIEW_TARGETto the cluster's URL.
Commit the workflow to the default branch. GitHub runs a pull_request_target workflow from there, not from the PR.
A few options change what setup enables. harper preview setup lists them all, and three need the matching action input in both jobs:
harper preview setup | What it does | Action input |
|---|---|---|
credential=true | Seals a GitHub token for the cluster to fetch each PR's commit with, for a private repository; setup offers it when it sees one. See Private repositories. | credential: true |
dataMode=shared | Lets previews use the cluster's databases rather than requiring a branch of them. | branched_databases: false |
domain=<domain> | Sets the preview domain, for a self-managed cluster or one you reach through a custom domain. | domain: <domain> |
maxPreviews=<n> | Caps how many previews of the application each node keeps live. A deploy past the cap is refused, so close PRs you are done with. | none |
maxTtlDays=<days> | The longest a preview lives after its last deploy, 7 by default. | none |
The cluster holds these limits in the preview role, and enforces them on every deploy, so a change to the workflow cannot raise them.
Create the GitHub environments
In the repository's Settings > Environments, create preview and preview-cleanup. On each, set Deployment branches and tags to the default branch only. GitHub creates an environment a workflow names with no rules at all, so create them before the first PR.
- On
preview, add required reviewers if the cluster also serves production and your GitHub plan supports them. Every deploy then waits for someone to approve the PR's code before it runs on the cluster. - Leave
preview-cleanupwithout reviewers. Its token can only drop previews, and a reviewer there would keep a closed PR's preview running until someone approves.
Public repositories: allow pull_request_target
From December 2, 2026, GitHub blocks pull_request_target by default in public repositories that have no Actions event policy of their own; it already did so during brownouts on November 9 and 16, 2026. Private and internal repositories are not affected. See GitHub's announcement.
In a public repository, allow the event for the preview workflow alone. In Settings > Actions > Policies, create a policy:
- Set its enforcement status to Active.
- Under its target workflows, add
.github/workflows/preview.yaml. - Turn on Restrict events, and allow
pull_request_target.
An organization or enterprise policy can still block the event. See Control workflow execution.
The workflow setup writes
When .github/workflows/preview.yaml doesn't exist, harper preview setup offers to write one, which runs the same lifecycle with harper preview commands instead of the action. It posts no PR comment and records no GitHub Deployment. For app=my-app and the default branch main:
The starter workflow
# Written by `harper preview setup`.
name: Preview
on:
pull_request_target:
types: [opened, reopened, synchronize, closed]
branches: ['main']
permissions: {}
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_PREVIEW_TARGET }}
PR: ${{ github.event.pull_request.number }}
jobs:
deploy:
if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview
permissions:
id-token: write
pull-requests: read
concurrency:
group: harper-preview-${{ github.event.pull_request.number }}
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
SHA: ${{ github.event.pull_request.head.sha }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm install --global harper@^5.4
- run: |
pr() { gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.state + " " + .head.sha'; }
before=$(pr)
if [ "$before" = "open $SHA" ]; then
harper preview deploy app=my-app pr="$PR" branch="$BRANCH" by_ref=true ref="$SHA" || deployed=$?
# Closed while this deployed: its cleanup may have run first.
after=$(pr)
if [ "${after%% *}" = closed ]; then harper preview drop app=my-app pr="$PR"; fi
exit "${deployed:-0}"
fi
cleanup:
if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: read
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm install --global harper@^5.4
- run: |
# A deploy still holding the preview answers 409 until it finishes; a reopened PR keeps its preview.
for attempt in 1 2 3 4 5; do
if state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .state); then
[ "$state" = closed ] || exit 0
harper preview drop app=my-app pr="$PR" && exit 0
fi
sleep 30
done
exit 1
It reads the PR's state and head commit with gh before it deploys, and again afterward, and passes ref="$SHA" with by_ref=true: without it, by_ref deploys GITHUB_SHA, which for pull_request_target is the base branch's commit. With credential=true, dataMode=shared or a domain= other than the default, setup adds the matching harper preview deploy argument. The rules in Add the preview workflow apply to it too.
Managing previews
As a super_user, or as the preview user:
harper preview list app=my-app # each preview: PR, branch, commit, URL, expiry
harper preview drop app=my-app pr=42
- Expiry. Each deploy sets the preview to expire
ttldays later, 7 by default and never more than the application'smaxTtlDays. One node drops each expired preview on every node, checking everydeployment.previews.sweepInterval. - How it is set up.
get_preview_policyreturns the application's identity, policies and limits, and inproblems, anything on the cluster that differs from whatenable_previewswrote. - A failed preview. A preview that fails to load in its canary worker fails the deploy, and the job's log says why.
- Turning previews off.
harper preview teardown app=my-apprevokes the identity, drops every preview of the application on every node, and removes the user, role and trust policies. It leaves the workflow file and the repository variable for you to remove.
Sweeping missed previews
The cluster expires every preview on its own, so a missed close event already costs no more than the preview's remaining days. A scheduled sweep removes those previews sooner: the action, run on schedule or workflow_dispatch, drops each of this repository's previews whose PR has closed. The trust policies setup writes match only pull_request_target, and the sweep has to list what is deployed, so it needs its own workflow file and its own policy. Commit .github/workflows/preview-sweep.yaml:
name: Preview sweep
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions: {}
jobs:
sweep:
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: write
deployments: write
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: my-app
preview: true
Then, as a super_user, add its policy, with your repository's id and name, your default branch, and the cluster's URL:
harper add_oidc_trust id=github-actions-my-app-preview-sweep \
issuer=https://token.actions.githubusercontent.com \
audience=https://my-cluster.example.com:9925/ \
user=my-app-ci-preview \
operations='["get_components","drop_component"]' \
claims='{"repository_id":"67890","workflow_ref":"my-org/my-app/.github/workflows/preview-sweep.yaml@refs/heads/main","event_name":["schedule","workflow_dispatch"],"environment":"preview-cleanup"}'
harper preview teardown doesn't know about this policy, so drop it yourself when you turn previews off: harper drop_oidc_trust id=github-actions-my-app-preview-sweep.
Private repositories
The cluster fetches each PR's commit itself, so for a private repository it needs a GitHub token that lasts longer than the workflow run. harper preview setup credential=true asks for one, seals it on your machine with the cluster's public key, and grants it to every preview of the application at once, as the secret deploy.my-app-pr-_.git.github.com. Pass credential: true to the action in both jobs to deploy with it; the starter workflow setup writes passes credential=true already.
Use a token for this alone: a fine-grained personal access token with Contents: read-only on this repository. Not the token production deploys with. Harper uses it only to install each preview; the previews' own code cannot read it. To rotate it, run harper deploy setup=true provider=github project='my-app-pr-*' with the new token.
Applications that need a build
A preview deploys by reference, so every node installs the PR's commit as it is in the repository. That suits an application that runs from source. Don't add a build step to the preview workflow: any step in a job that holds the identity token could use it, and a build runs the PR's code and its dependencies.
To preview an application that needs a build, build and publish each PR's commit from a separate pull_request workflow, which gets no identity token, and deploy the published package from the preview job, with the action's args input (args: package=npm:@my-org/my-app@pr-${{ github.event.pull_request.number }}). GitHub starts both workflows at once, and the trust policies match only pull_request_target, so the preview job has to wait for that build itself.
How previews stay safe
- The workflow is trusted, not the PR. The workflow runs on
pull_request_target, from the default branch, and never runs the PR's code on the runner. The trust policies pin itsworkflow_refto the default branch,event_nametopull_request_target, and its environment, so a PR that edits the workflow cannot change what runs with the identity: its edits take effect only once it merges. - Forks get nothing. Each job runs only for a PR from a branch of the same repository. A fork's PR never gets the identity, and no preview.
- The PR's code still runs on the cluster. The preview role can deploy and drop only
my-app-pr-<n>, as an isolated application under the preview domain, within the application's limits, with no table permissions. Harper does not sandbox an application's code, though: a preview runs in the Harper process like any application, and can reach what any application can. So anyone who can open a PR from a branch of the repository can run code on the cluster. Required reviewers on thepreviewenvironment are the gate for that. - Previews share a site with production. A preview's host is under the cluster's own host name, so a browser treats it as the same site as anything production serves from that host name. A preview can set cookies production receives, and
SameSitedoes not keep them apart: see Previews share a site with production for__Host-cookie names and CSRF orOriginchecks. What production serves only from a custom domain is a different site. - Revoking.
harper preview teardownrevokes the identity at once: it drops the trust policies and deactivates the user, which also stops a token already issued, before it drops the previews. To stop CI without dropping the previews, deactivate the user alone:harper alter_user username=my-app-ci-preview active=false.
Shared or branched data
With the default dataMode=branched, the cluster refuses a preview deploy that does not branch, and each preview gets a private copy of every database except system, taken when it first loads. Its writes, and the tables and schema changes it makes, stay in its copy, which is removed when the preview is dropped. Some things are still shared:
- The
systemdatabase, users and roles, cannot be branched. Roles a preview declares apply to the whole cluster. - A database created after the preview loads is not branched.
- Work outside the database, such as calls to other services, or files written outside the application's own directory, happens once per preview.
With dataMode=shared, previews read and write the cluster's databases, the ones production uses if it runs on the same cluster. A preview's writes, schema changes and startup work then change production's data. Use it only for an application whose data a preview may change, or on a cluster for previews alone.
Previews on v5.3
None of the above exists before v5.4.0. On v5.3, a preview needs a role, user, trust policy and workflow written by hand, and that role can deploy and drop any component on the cluster. On Fabric each preview host is a custom domain to add, verify and bind; a payload deploy cannot be isolated or branched; and cleanup has to call every node itself. The v5.3 recipe is in the previous version of this guide. Run v5.3 previews on a cluster for previews alone, or behind required reviewers when they share a cluster with production.
Other CI systems
The Harper CLI detects GitHub Actions' identity token only. On another CI system it falls through to its other credential sources, so give the pipeline a stored credential for the ci-deploy user instead. The setup above discarded that user's password, so first set one you keep: generate it in your password manager, then, logged in as a super_user, run:
read -rs CI_DEPLOY_PASSWORD
harper alter_user username=ci-deploy password="$CI_DEPLOY_PASSWORD"
Keep that password in the CI system's secret store. To store a 30-day refresh token instead, run harper login --for-ci as ci-deploy with it. That also replaces your own saved login for the target, so run harper login as yourself afterward. The deploy-only role still applies: name private-source credentials with secret references, since a literal token needs super_user.
Without the CLI, call the Operations API with curl. HARPER_OPS_URL is the cluster's Operations API endpoint (https://my-cluster.example.com:9925), HARPER_OPS_AUTH is the full Authorization header value for the ci-deploy user (Basic <base64 user:password>), and RELEASE_TAG is the tag being released, such as v1.2.3:
set -euo pipefail
curl --fail-with-body -s "$HARPER_OPS_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $HARPER_OPS_AUTH" \
-d @- <<EOF | tee deploy.json
{
"operation": "deploy_component",
"project": "my-app",
"package": "github:my-org/my-app#semver:${RELEASE_TAG}",
"credentials": [{ "host": "github.com", "secret": "deploy.my-app.git.github.com" }],
"restart": "rolling"
}
EOF
#semver:v1.2.3 resolves the reference through your repository's semver tags; #<sha> pins an exact commit. For a registry deploy, use the package and credentials from Path B. set -euo pipefail stops the script at the first failed command, including a curl whose output goes through tee, so a failed deploy never reaches the wait below.
Then wait for every node. When the response has "status": "rolling_out" Added in: v5.4.0, the deployment record follows the rolling job, and it replicates, so any node can answer for it. Poll get_deployment with the same header until the record finishes:
set -euo pipefail
ID=$(jq -r '.deployment_id' deploy.json)
for attempt in $(seq 60); do
sleep 10
RECORD=$(curl --fail-with-body -s --max-time 30 "$HARPER_OPS_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $HARPER_OPS_AUTH" \
-d "{\"operation\": \"get_deployment\", \"deployment_id\": \"$ID\"}") || continue
case $(echo "$RECORD" | jq -r '.status') in
success) exit 0 ;;
partial | failed) echo "$RECORD" | jq -r '.error.message, (.error.nodes // [] | join(", "))'; exit 1 ;;
esac
done
echo "After 60 polls, deployment $ID was still rolling out"
exit 1
A node the record has not replicated to yet answers with an error or an earlier status, which the loop polls again. The loop gives up after 60 polls, about ten minutes, where harper deploy allows ten minutes for each node, so on a cluster with several nodes raise the count. Otherwise, on a cluster before v5.4.0 or when certification is unavailable, the response has no status and only the job follows the other nodes, so poll get_job for it with the same header:
set -euo pipefail
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || { echo "The rolling deploy returned no restartJobId"; exit 1; }
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
JOB=$(curl --fail-with-body -s --max-time 30 "$HARPER_OPS_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $HARPER_OPS_AUTH" \
-d "{\"operation\": \"get_job\", \"id\": \"$JOB_ID\"}") || continue
case $(echo "$JOB" | jq -r '.[0].status // "MISSING"') in
COMPLETE) exit 0 ;;
ERROR) echo "$JOB" | jq -r '.[0].message'; exit 1 ;;
MISSING) LAST="was not on the node that answered; its outcome is unknown" ;;
*) LAST="was still running" ;;
esac
done
echo "After 60 polls, job $JOB_ID $LAST"
exit 1
If your CI system issues its own OIDC tokens, a client can trade one for an operation token with exchange_oidc_token, against a trust policy that pins the token's sub (see Other issuers).
Operational notes
- Going back to a previous release. Added in: v5.3.0 When a deploy replaces the live release, Harper keeps the replaced one under its
deployment_id. Activating that id puts it back with no rebuild or reinstall —harper deploy project=my-app deployment_id=<id> restart=rolling— andlist_deploymentsshows the ids. See Going back to a previous release for which releases are kept. To deploy a commit or version that is no longer kept, re-run the deploy with its tag or version — which is why pinned references (#semver:v1.2.3,@my-org/my-app@1.2.3, a commit SHA) beat branch references (#main) in a pipeline. - Rotating the deploy credential: run
harper deploy setup=trueagain (orset_secret, or an edit in Harper Studio) with the same name and a new value. The pipeline doesn't change. - Revoking the pipeline's access: drop the trust policy with
drop_oidc_trust, or deactivate theci-deployuser withalter_user. There is no CI secret to rotate. - Git-host credentials require git ≥ 2.31 on the Harper nodes, and are not supported on Windows nodes (the deploy fails with a clear error rather than degrading security). Registry credentials have no such constraints. Fabric instances satisfy both.
- Self-managed Harper core without the Pro secrets component:
secretreferences can't be resolved (no decryption custody), and a literaltokenis used transiently for that node's install only — it is not persisted for later installs. The durable-credential patterns in this guide assume custody (Fabric provides it).
Additional Resources
- CLI Authentication: workload identity (OIDC) — the client half of the exchange, and where it sits in credential precedence
- OIDC trusted publishing —
add_oidc_trustand the rules a policy must satisfy harper deploy— every CLI deploy parameterdeploy_componentand deploy credentials reference- Secrets store reference — the encrypted store behind
secretreferences - Deployment records —
list_deployments,get_deployment - GitHub: OpenID Connect in GitHub Actions
- GitHub: fine-grained personal access tokens
- GitHub Packages: npm registry