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
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.
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"]}'
harper add_user username=ci-deploy role=ci_deploy active=true password="$(openssl rand -base64 32)"
deploy_componentis the deploy itself.get_jobis what the wait step polls. 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.get_deploymentreads the deployment record.
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. Keep it only if you deploy from another CI system.
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@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g harper
- 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: |
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
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" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
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.
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. The CLI prints which policy authenticated it, and as whom, to stderr. 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=<tag or sha>to deploy a different commit.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 the cluster keeps serving throughout. Userestart=truefor a single-node or dev instance. 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.- The wait step. With
restart=rolling,harper deployexits0once the node you called has taken the release and started the rolling job, which keeps running after the deploy step has passed. On v5.4.0 and later, unless the deploy'scertificationisunavailable, the other nodes take the release in that job, one at a time, and a node that rejects it fails the job, not the deploy step; the failed job'smessagelists each node's outcome underactivated. Otherwise every node installs the release before the deploy step passes, and the job only restarts them. The wait step 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. 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. Everything else about a deploy is visible from any node, but Harper does not replicate jobs, so only the node that received the deploy knows the rolling job. Any other node answers
get_jobwith an empty list, which the wait step treats as not finished and polls again. Through a load-balanced cluster URL, the polls that reach the node that received the deploy see the job; if none do, the step says the job was not on the node that answered. harper#3060 tracks recording each node's outcome on the deployment record, which replicates. - 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:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
id-token: write
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: '@my-org'
- run: npm install -g harper
- run: npm ci
- run: npm run build
- run: npm test
- name: Publish to GitHub Packages
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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: |
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
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" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
exit 1
Note the two credentials play different roles, matching their lifetimes:
- Publishing uses the workflow's ephemeral
GITHUB_TOKEN(withpackages: write). Publishing happens entirely inside the job, so an ephemeral token is exactly right. - 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 npm install -g harper still installs 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 same role:
harper get_deployment deployment_id="$(jq -r .deployment_id deploy.json)" json=true
Only the node you called writes that record, so with restart=rolling on v5.4.0 and later it can report success before the job has visited the other nodes, and keeps reporting it if one of them rejects the release. Wait on the job, not the record, to learn whether they took the release. list_deployments gives you the history of what was deployed and when, as recorded by the node that received each deploy.
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 — a refresh token from harper login --for-ci, or that user's password — and keep it in the CI system's secret store. 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 the job the same way, polling get_job with the same header:
set -euo pipefail
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
JOB=$(curl --fail-with-body -s "$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" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
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