CI/CD Reference
Setting up your first workflow? Start with Deploying from GitHub Actions — one file, five minutes. This page is the reference behind it.
How to run the pb CLI from an automated pipeline — GitHub Actions
throughout, though nothing here is Actions-specific — and what goes wrong when
you do.
A CI run differs from your laptop in four ways, and every recipe below exists to handle one of them:
| On your laptop | On a runner |
|---|---|
pb cloud login opens a browser |
No browser — authenticate with PB_TOKEN |
| A prompt waits for you | Nothing answers it — --no-input |
pb.json remembers what you deployed |
Only what you committed is there |
| You read the output | A machine does — --json, and exit codes |
1. Authenticate with PB_TOKEN
PB_TOKEN is a PocketBase Cloud user token. When it is set it takes
precedence over any saved login, and the CLI always talks to PocketBase
Cloud’s own hosts — there is nothing else for a CI job to configure.
Getting one
Copy it from the portal: Account → CLI access token → Copy. The page also shows when it expires.
Already logged in with the CLI? It is in the config file too.
pb cloud login saves the same token to disk, so a shell one-liner works as
well:
jq -r .cloud.userToken ~/.config/pb/config.json
(On Windows: %USERPROFILE%\.config\pb\config.json. If XDG_CONFIG_HOME is
set, the file is $XDG_CONFIG_HOME/pb/config.json.)
Store the value as a repository secret named PB_TOKEN
(Settings → Secrets and variables → Actions → New repository secret), then
expose it to the job:
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
Verify it before doing anything else — whoami is the cheapest possible
preflight and tells you which account and plan the job is acting as:
pb cloud whoami --json # {"id":"…","email":"…","plan":"pro"}
Tokens expire after 365 days
A user token is valid for a year (31 536 000 s). Nothing refreshes it for you, so a pipeline set up today needs the secret replaced once, a year from now, when every job starts failing with:
Error: Not authenticated. Run `pb cloud login`. (exit 4)
Replacing it is the same copy from the Account page, which prints the expiry
date beside the token. To check a token you already hold — the one in a CI
secret, say — decode it yourself; the payload’s exp is a Unix timestamp:
# Expiry of the token in $PB_TOKEN. The JWT payload is base64url and unpadded,
# so translate the alphabet and pad it before decoding.
p=$(cut -d. -f2 <<<"$PB_TOKEN" | tr '_-' '/+')
printf '%s%s' "$p" "$(printf '=%.0s' $(seq $(((4 - ${#p} % 4) % 4))))" |
base64 -d | jq -r '.exp | todate'
A year is a long time for a credential to sit in a settings page, so treat it like one: keep it out of anywhere a fork PR can read, and remember that a new login does not invalidate the old token — both stay valid until their own expiry. A leaked token has to be cut off at the account (a PocketBase auth token is signed with the user’s token key, so rotating that key is what invalidates every token issued for it), not by logging in again.
Need it sooner than a year?
POST /api/collections/users/auth-refreshwith the current token in theAuthorizationheader returns a fresh one, which is what an automated rotation job would use.
Secrets and pull requests
GitHub does not pass secrets to workflows triggered by pull_request from
a fork. Those jobs see an empty PB_TOKEN and exit 4. Deploy from push on
your own branches, or gate the job:
if: github.event.pull_request.head.repo.full_name == github.repository
Don’t leak it
pb never prints env var values, and --json output carries none — but your
own steps can. Never echo "$PB_TOKEN", never set -x around a curl carrying
it, and prefer env: over interpolating ${{ secrets.PB_TOKEN }} directly
into a run: script.
2. Install pb on the runner
npm, pinned (recommended)
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g @pocketbasecloud/cli@0.3.1
Pinning is the point: an unpinned install makes every run depend on whatever
was published that morning. Node ≥ 18 is required; the package pulls in one
prebuilt binary through optionalDependencies.
npx, no install
- run: npx -y @pocketbasecloud/cli@0.3.1 cloud frontend deploy --no-input --json
curl | sh (no Node)
- name: Install pb
env:
# Pin the location rather than letting the script choose between
# /usr/local/bin and ~/.local/bin — then the PATH line below is always right.
PB_INSTALL_DIR: ${{ runner.temp }}/pb-bin
run: |
curl -fsSL https://raw.githubusercontent.com/pocketbasecloud/cli/main/scripts/install.sh | sh
echo "$PB_INSTALL_DIR" >> "$GITHUB_PATH"
The installer verifies the archive’s SHA-256 against the release’s
checksums.txt and always takes the latest release — it has no version
pin, so prefer npm when reproducibility matters.
Container gotchas
- Alpine / musl does not work. The binaries are compiled against glibc
(
x86_64-unknown-linux-gnu). Undercontainer: node:20-alpinethe shim resolves the right package and then fails to exec it (pb: failed to run …, or a barenot found). Use a glibc image —node:20-bookworm-slim,ubuntu-latest, and the default GitHub runners are all fine. --no-optional/--ignore-optionalskips the binary package, andpbthen reportsno prebuilt binary for <host>. Install without them.- Supported hosts:
linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64(Windows on ARM runs the x64 build under emulation).
Update notices
pb checks for a newer release of itself at most once a day and prints a
single line on stderr. It stays quiet under --json, when output is
redirected, and when CI is set — so GitHub Actions never sees it. On a
runner that does not set CI, set PB_NO_UPDATE_CHECK=1.
3. Make every command non-interactive
pb cloud frontend deploy --no-input --json
--no-input— fail with a usage error instead of prompting. A runner has no TTY, sopbalready refuses to ask there; passing the flag states the intent, and covers the runner or container that does allocate one.--json— one machine-readable object on stdout, everything else (build output, progress, warnings) on stderr, sopb … --json | jqis safe. Errors are{"error":"…"}on stderr with a non-zero exit.--jsonalso implies “never prompt”.--yes— required by anything destructive:rm, andenv import --delete-missing. Without it you getThis action needs confirmation. Pass --yes to proceed.(exit2).--no-inputdoes not imply consent.
Because nothing can be asked, CI has to supply what a prompt would have answered:
| Question a terminal would ask | What CI passes |
|---|---|
| Which project? | --project <name|id>, or a committed pb.json |
| Which resource / what to name it? | --name <name> (or a committed binding) |
| Which environment? | --env <name> or PB_ENV; default production |
| Which compute? (Pro, or an org project with several) | --compute <id> — see pb cloud compute ls |
| Which dotenv file to push? | --env-file <path>, --skip-env, or the envFile recorded in pb.json |
Anything not answered is simply not done: with no envFile configured and no
flag, a CI deploy pushes no environment variables, and records no answer in
pb.json — so a later interactive deploy still gets asked.
4. Tell CI what to deploy
Commit pb.json (the easy path)
pb.json binds a directory to a cloud resource. Deploy once from your laptop,
commit the file, and CI needs no flags at all:
// web/pb.json
{
"projectId": "dhs4xnprgplurvo",
"kind": "frontends",
"defaultEnvironment": "production",
"environments": {
"production": { "id": "…", "name": "web" }
},
"build": { "command": "npm run build", "outputDir": "dist" }
}
- run: pb cloud frontend deploy --no-input --json
working-directory: web
Two things follow from the file being committed:
- The build config is reviewed in git rather than re-inferred on every run.
- A binding whose resource was deleted is an error, not a silent re-create:
Bound frontends … no longer exists — pass --name to recreate.
Or pass --project and --name
Without a committed binding, name the target explicitly:
pb cloud frontend deploy --project my-app --name web --no-input --json
This is idempotent. --name matches an existing resource in the project and
redeploys it; only when nothing matches is a new one created. So a
workflow that always passes the same --name will not accumulate duplicates.
A deploy still writes the resolved binding into the runner’s
pb.json. It is thrown away with the workspace — but agit diff --exit-codestep after a deploy will notice it.
pb cloud deploy in a pipeline
pb cloud deploy detects the kind from the directory and runs
pb cloud pb deploy, pb cloud frontend deploy, or
pb cloud backend deploy — see
One deploy command for all three
for the rules. It is safe in CI with one caveat worth stating plainly:
- With
pb.jsoncommitted there is nothing to detect. The recordedkindis the first rule, so the pipeline runs the same command every time, whatever the working tree looks like. - Without it, detection reads the checkout. That is a build artifact of
your repo layout, so a refactor could in principle change what a pipeline
deploys. Either commit
pb.jsonor name the kind —pb cloud deploy backend --no-input --json— if you want the workflow file to be the record. - It never prompts under
--no-inputor--json. A directory it cannot classify exits 2 naming the three explicit commands, rather than hanging.
Monorepos
pb.json is found by walking up from the working directory, so a job run
from the repo root can resolve a parent’s file and target the wrong thing.
Always pin the directory:
defaults:
run:
working-directory: apps/web
pb.json also records exactly one kind per directory. Deploying a backend
from a directory bound to frontends fails with
pb.json is bound to frontends — deploy backends from a different directory.
Environments
One directory can target several cloud resources — staging and production, in
the same project. Pick one with --env, or PB_ENV for a whole job:
env:
PB_ENV: staging
--env naming an environment pb.json does not have is an error everywhere
except deploy, which creates it — and creating needs a --name to create it
under. Order of precedence: --env > PB_ENV > defaultEnvironment > the
sole configured entry > production.
5. Environment variables and secrets
Nothing is pushed unless a file is named. On a laptop the first deploy of
an environment asks which dotenv file to use and records the answer in
pb.json; in CI nothing is asked, so the file must already be configured or
named on the command line.
The catch: dotenv files are usually gitignored, so the envFile recorded in
pb.json is not on the runner, and a named-but-missing file fails the
deploy before anything is provisioned:
Error: Env file not found: .env.production (exit 2)
Three ways out, in order of preference:
a) Set the variables once, out of band. They live on the platform, encrypted, and survive redeploys:
pb cloud env set 'STRIPE_KEY=sk_live_…' --target backend --name my-app-api
KEY=VALUE is one argument — quote it, or a value containing a space, $, or
; will be mangled by the shell before pb ever sees it. The key is
everything before the first =; the rest is the value verbatim.
Then let deploys leave them alone with --skip-env.
b) Write the file from secrets, then deploy.
- name: Materialise .env.production
working-directory: api
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
umask 077
{
printf 'STRIPE_KEY=%s\n' "$STRIPE_KEY"
printf 'SENTRY_DSN=%s\n' "$SENTRY_DSN"
} > .env.production
- run: pb cloud backend deploy --env-file .env.production --no-input --json
working-directory: api
printf rather than a heredoc on purpose: a secret containing $, a backtick,
or a backslash survives it intact.
Dotenv files never travel inside the deploy archive, and values are never printed.
What the parser accepts. pb’s dotenv reader is deliberately minimal, and
a file written for docker compose or dotenv can mean something different
here. Per line: blank lines and lines whose first non-space character is #
are skipped, the key is everything before the first =, the value is
everything after it, and both are trimmed. That means:
| Written | Value the platform stores |
|---|---|
KEY=plain value |
plain value |
KEY="quoted" |
"quoted" — quotes and all |
KEY=a=b |
a=b (only the first = splits) |
KEY=v # comment |
v # comment |
export KEY=v |
key export KEY — broken |
KEY=line1\nline2 |
not supported — one line per key |
So: no quoting, no export, no trailing comments, no multi-line values. Base64
anything that needs newlines (a PEM key, a JSON blob) and decode it in the app.
--env-file is relative to the resource directory, not the repo root and
not the shell’s $PWD if they differ — an absolute path is joined onto the
directory and will not be found. Keep the file beside pb.json.
c) Import separately, when you want the file to be the whole truth:
pb cloud env import .env.production --target backend --name my-app-api \
--delete-missing --yes
An import merges by default — keys in the file are written, cloud-only keys
left alone. --delete-missing removes the cloud-only keys as well, and
requires --yes in CI. (The same flag on deploy prunes without asking.)
Frontends have no cloud env store. Their variables are baked in at build time, so they belong in the build step:
- run: npm run build
working-directory: web
env:
VITE_API_URL: ${{ vars.VITE_API_URL }}
- run: pb cloud frontend deploy --skip-build --no-input --json
working-directory: web
6. GitHub Actions recipes
Two conventions run through all of them:
- Each recipe assumes the resource directory’s
pb.jsonis committed, so the project resolves without a flag. If it is not, add--project <name|id>to everypb cloudcommand. - A deploy’s
--jsonobject is the platform’s resource record plusenvironmentand (on a first deploy)reachable. The useful fields areid,name,status,domain, and — on PocketBase only —baseUrl. Hencejq -r '.baseUrl // ("https://" + .domain)', which covers all three kinds.
Never print a PocketBase deploy’s JSON.
adminUsernameandadminPasswordare fields on thepocketbasesrecord, so they are in that object — and GitHub only masks values it knows are secrets, which a generated password is not.cating it, echoing it into$GITHUB_OUTPUT, or uploading it as an artifact puts a superuser password in the build log in cleartext, readable by anyone who can see the run. Select the fields you need withjqand let the rest stay in the file.
Two habits worth adopting before you copy anything below:
- Give deploy jobs a
timeout-minutes. A deploy waits on provisioning (5 min) and then on DNS and a certificate (2 min); if something wedges, GitHub’s default is to let the job run for six hours.timeout-minutes: 20is generous for any recipe here. - Pin actions by commit SHA (
actions/checkout@<sha> # v4) if you care about supply-chain risk. Major-version tags are mutable; the examples use them for readability.
6.1 Static site on every push to main
name: Deploy web
on:
push:
branches: [main]
paths: ["web/**", ".github/workflows/deploy-web.yml"]
workflow_dispatch:
# Two runs deploying the same resource at once is a race; queue them instead.
concurrency:
group: deploy-web-production
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
steps:
- uses: actions/checkout@v4
# `cache: npm` fails the step outright when the lockfile it is told to
# hash does not exist — drop both cache lines if web/ has no
# package-lock.json.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm i -g @pocketbasecloud/cli@0.3.1
# Fails fast with exit 4 on a missing or expired token, before any build.
- run: pb cloud whoami --json
- run: npm ci
# Redirect, never `| tee`: in a pipeline $? is the *last* command's
# status, so a failed deploy piped anywhere reports success.
- name: Deploy
run: pb cloud frontend deploy --name web --no-input --json > deploy.json
- name: Summarise
run: |
# `.domain` is absent until the platform records one, and jq treats
# null as identity for +, so an unguarded expression yields the
# cheerful nonsense "https://" and a green build.
url=$(jq -r '.baseUrl // ("https://" + .domain)' deploy.json)
[ "$url" != "https://" ] || { echo "::error::deploy reported no URL"; exit 1; }
echo "🚀 Deployed to $url" >> "$GITHUB_STEP_SUMMARY"
pb installs dependencies itself when something package.json declares is
missing, so the npm ci step is optional — it is there for the lockfile
guarantee and the cache, not because the deploy needs it.
6.2 Full-stack monorepo, in dependency order
PocketBase first (the backend needs its URL), then the backend, then the site.
name: Deploy app
on:
push:
branches: [main]
concurrency:
group: deploy-app-production
cancel-in-progress: false
permissions:
contents: read
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
PB_CLI_VERSION: 0.3.1
jobs:
db:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g "@pocketbasecloud/cli@$PB_CLI_VERSION"
- id: deploy
working-directory: db
run: |
# out.json also holds adminUsername/adminPassword — take the one
# field the next job needs and never echo the object itself.
pb cloud pb deploy --name my-app-db --no-input --json > out.json
url=$(jq -r '.baseUrl // ("https://" + .domain)' out.json)
[ "$url" != "https://" ] || { echo "::error::no URL on the instance yet"; exit 1; }
echo "url=$url" >> "$GITHUB_OUTPUT"
api:
needs: db
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g "@pocketbasecloud/cli@$PB_CLI_VERSION"
# The URL travels with the deploy rather than through a separate
# `env set`: on the very first run the backend does not exist yet, so
# there would be nothing for `env set --name` to resolve. A deploy pushes
# the file's keys right after creating the record. Dotenv files are never
# packaged into the archive.
- name: Deploy with the instance URL
run: |
printf 'POCKETBASE_URL=%s\n' "${{ needs.db.outputs.url }}" > .env.ci
pb cloud backend deploy --name my-app-api \
--env-file .env.ci --no-input --json
web:
needs: api
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g "@pocketbasecloud/cli@$PB_CLI_VERSION"
- working-directory: web
env:
VITE_POCKETBASE_URL: ${{ needs.db.outputs.url }}
run: |
npm ci
npm run build
pb cloud frontend deploy --name my-app-web --skip-build --no-input --json
Backends are Pro-only. On a Pro account with more than one compute, add
--compute <id> (from pb cloud compute ls) — a job cannot answer the menu.
Three jobs because each hands a value to the next, and needs.<job>.outputs
is how a value crosses a job boundary. Nothing here runs in parallel, so you
pay three checkouts and three CLI installs for that plumbing. One job with
three steps, passing values through $GITHUB_ENV, is faster and simpler —
split them only when you want the stages to show up separately in the UI, or
to re-run one without the others.
6.3 Staging from pull requests, production from main
name: Deploy web (staged)
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: deploy-web-${{ github.event_name == 'push' && 'production' || github.head_ref }}
# Superseding a staging deploy is fine. Killing a production one mid-flight
# is not: the platform keeps provisioning after the runner dies, and you are
# left with a half-finished deploy nobody is watching.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
pull-requests: write
jobs:
deploy:
# Fork PRs get no secrets — skip rather than fail with exit 4.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
PB_ENV: ${{ github.event_name == 'push' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g @pocketbasecloud/cli@0.3.1
# --name OVERRIDES a committed binding, so it has to equal the name
# pb.json records for this environment — here, resources named
# web-production and web-staging. Get that wrong and the deploy creates a
# second resource instead of redeploying the bound one.
- run: |
pb cloud frontend deploy \
--name "web-$PB_ENV" --no-input --json > deploy.json
# --edit-last keeps one comment updated instead of adding another on
# every push; --create-if-none covers the first run, where there is
# nothing to edit.
- name: Comment the staging URL
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
url=$(jq -r '.baseUrl // ("https://" + .domain)' deploy.json)
gh pr comment "$PR" --edit-last --create-if-none \
--body "Staging deploy: $url"
Do not reach for
pull_request_targetto give fork PRs a token. It runs the base repository’s workflow with full secrets in the context of untrusted code, and combined with a checkout of the PR head it is the standard way repositories get their secrets stolen. If outside contributors need a preview, deploy it from a separateworkflow_runjob that checks out no fork code, or don’t.
Watch your plan limits. A resource per branch is a resource per branch:
free and starter allow 1 PocketBase and 5 frontends, and the sixth deploy
fails with exit 3. One long-lived staging environment (as above) is far
safer than a resource per PR. If you do deploy per PR, tear it down on close —
a second workflow file:
name: Clean up PR deploy
on:
pull_request:
types: [closed]
permissions:
contents: read
jobs:
cleanup:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: web
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g @pocketbasecloud/cli@0.3.1
# A name that is already gone resolves to nothing, so `rm` fails with
# "Specify a unique --name or --id." (exit 2). A cleanup job should not
# go red because it ran twice — but swallowing *every* failure would also
# hide an expired token, so say something when it happens.
- run: |
pb cloud frontend rm --name "web-pr-$PR" --yes --json ||
echo "::warning::nothing removed for web-pr-$PR (already gone, or the token is bad)"
6.4 Capture logs when a deploy fails
Exit 6 means the resource settled in a failed state — the container’s own
logs say why. Two steps to drop into any of the jobs above:
- name: Deploy
id: deploy
working-directory: api
run: pb cloud backend deploy --name my-app-api --no-input --json
# Without -f, `logs` prints the last --lines entries and exits. Never pass -f
# in CI: it follows the container until the job's timeout.
- name: Logs on failure
if: failure() && steps.deploy.outcome == 'failure'
working-directory: api
run: pb cloud logs backend --name my-app-api --lines 200 || true
6.5 Scheduled data export
name: Nightly export
on:
schedule:
# GitHub cron is UTC, and scheduled runs are best-effort — they can be
# delayed or skipped when the platform is busy.
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
export:
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: db
env:
PB_TOKEN: ${{ secrets.PB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g @pocketbasecloud/cli@0.3.1
# --out is relative to the working directory, so this lands in db/.
- run: pb cloud data export --name my-app-db --out data.zip --no-input
- uses: actions/upload-artifact@v4
with:
name: pocketbase-export
path: db/data.zip
retention-days: 7
Two things about this one that bite later, neither of them about pb:
- A build artifact is not a backup. Anyone who can read the repository can download it, it is not encrypted at rest by you, and it disappears on the retention clock. For a real backup, push the archive to object storage you control — or use the instance’s own Settings → Backups, which never leaves the platform. Keep the artifact route for ad-hoc snapshots only, and think twice on a public repository, where the whole world is “anyone who can read the repository”.
- GitHub disables scheduled workflows after 60 days without repository
activity, and emails the last committer rather than failing loudly. A
quiet repo silently stops backing itself up.
workflow_dispatchis in the triggers above so you can at least confirm it still runs.
(pb cloud data import is not implemented — importing needs a target
collection and a field mapping, so use the portal’s import dialog.)
7. Exit codes
| Code | Meaning | Typical cause |
|---|---|---|
0 |
Success | Also when a new domain was not reachable yet |
1 |
Unexpected / platform error | A 4xx or 5xx the CLI could not classify |
2 |
Usage error | Missing --name, --project, --yes; bad path |
3 |
Not permitted | Plan or slot limit, org rights, no compute available |
4 |
Not authenticated | PB_TOKEN missing, wrong, or expired |
5 |
Timed out | Provisioning ran past the deadline — resource exists |
6 |
Finished in a failed state | The resource ended error/failed, not running |
7 |
Your build command failed | npm run build (or the install step) exited non-zero |
Two behaviours worth knowing before you branch on these:
- An unreachable new domain is not a failure. After a first deploy the CLI
waits for DNS and the certificate; giving up on that wait leaves the exit
code at
0, because the resource is running. Under--jsonthe outcome is reported as"reachable": true|false. - A timeout (
5) still created the resource. The error names theinfoandrmcommands for it. Do not retry blindly — you will redeploy something that is already provisioning.
Reacting to a specific code:
- name: Deploy, tolerating a slow certificate
run: |
set +e
pb cloud frontend deploy --name web --no-input --json > deploy.json
code=$?
set -e
case $code in
0) ;;
5) echo "::warning::Still provisioning — check pb cloud frontend info" ;;
# The failure message is {"error":"…"} on stderr, already in the log —
# deploy.json holds nothing on a failed run.
*) exit $code ;;
esac
8. Common errors and how to fix them
Authentication
Error: Not authenticated. Run 'pb cloud login'. (4)
PB_TOKEN is unset, malformed, or expired. In a pipeline that has been running
fine, the usual cause is that the secret was never wired into this job —
env: is per job or per step, not inherited from another job — or that the run
is a fork PR. In a pipeline that has been running for a year, decode the
token’s exp (§1) and copy a fresh one from the portal’s Account page.
Warning: PB_TOKEN is set and takes precedence …
Harmless in CI. It only appears if something in the job also ran
pb cloud login/logout; the env token wins, which is what you want.
Permission denied. This action may require organization owner rights. (3)
The token belongs to an account without rights on this project. Confirm with
pb cloud whoami --json — CI is frequently authenticated as a different human
than the one who created the project.
Targeting the wrong thing (or nothing)
No project selected. Pass --project or run 'pb cloud project use'. (2)
No pb.json above the working directory, and no --project. Either commit
the binding or pass the flag. Check working-directory first — this is
usually a job running at the repo root.
Pass --name to create the first frontend. (2) /
Environment "staging" is not configured — pass --name to create it. (2)
Nothing tells the deploy what to target. Add --name, or commit a pb.json
that already binds this environment.
Bound frontends abc123 (environment "production") no longer exists — pass --name to recreate. (2)
The committed binding points at a deleted resource. Pass --name once to
recreate it, then commit the refreshed pb.json.
Multiple frontends named "web". Pass --id. (2)
Two resources share the name. Use --id, and rename one.
pb.json is bound to frontends — deploy backends from a different directory. (2)
One directory is one kind, forever. Give each resource its own directory.
Multiple environments configured (production, staging). Pass --env <name>. (2)
defaultEnvironment was removed from pb.json. Set --env/PB_ENV, or put
the key back.
Unknown environment "prod". Configured: production, staging. (2)
A typo in PB_ENV. Only deploy may create an environment that does not
exist; every other command requires an existing one.
Prompts that cannot be answered
Input required but running non-interactively (--no-input). (2)
Something needed an answer. Re-read §3 and supply the flag; the message
following it usually names the missing value.
This action needs confirmation. Pass --yes to proceed. (2)
rm, or env import --delete-missing. Add --yes.
This project has 2 computes — pass --compute <id>: (2)
Pro (or an org project) with more than one compute. Take the id from
pb cloud compute ls and pass --compute. Only creation picks a compute —
a redeploy never moves a running resource.
--interactive cannot be combined with --no-input. (2)
Drop --interactive; it has no place in CI.
Plan and capacity
Backend deployments require a Pro plan — upgrade from the Plan page (3)
Backends are Pro-only. The plan that matters is the project owner’s, not
the CI account’s — in a shared org project, a free-plan member deploys against
the owner’s Pro plan.
No available PocketBase slots — buy more from the Plan page (3)
(and No available Frontend slots ….) Free and Starter allow 1 PocketBase and
5 frontends. A per-branch deploy strategy hits this quickly; prefer one
long-lived staging environment, and clean up on PR close.
No plan — subscribe first to deploy resources (3)
The account has no subscription at all — even the free tier requires one, to
initialise the deployment slots. Subscribe in the portal, then re-run.
No running compute on this account yet… (3)
A new Pro compute takes a few minutes to provision. Check
pb cloud compute ls and retry — this is a wait, not a misconfiguration.
Project still has 1 backends, 1 frontends. Delete them first. (2)
project rm does not cascade. Remove resources in dependency order:
backends → frontends → PocketBases → project.
Build and packaging
Build failed (npm run build exited 1). (7)
Your build, not the CLI. It reproduces locally with the same command. Note
that under --json the build’s own output goes to stderr — make sure the
job is not discarding it.
Installing dependencies failed (npm install exited 1 in /…). (7)
pb runs the install itself when the tree is incomplete, using whichever
package manager the lockfile names, at the workspace root. Install
dependencies in an earlier step, or set "install" in the build block (""
turns the step off).
code.zip is 132.7 MB, over the 100.0 MB limit. (2)
The platform’s archive ceiling. A CI runner is where this appears first,
because a cold clone builds everything: check that source maps are off for
production and add anything else large to exclude in the build block.
node_modules is already excluded everywhere except a Next.js standalone
bundle, which needs the pruned copy Next produces.
Nothing to deploy — the packaged zip is empty. (2)
outputDir points somewhere the build did not write — the usual cause is a
build that ran in a different directory, or dist vs build. Fix the build
block in pb.json.
Env file not found: .env.production (2)
pb.json names a dotenv file that is gitignored and therefore absent on the
runner. See §5 — write it from secrets, --skip-env, or move the variables
into the platform’s env store.
Next.js: the deploy rewrote next.config.js.
nextjs backends ship a prebuilt standalone bundle, so deploy adds
output: "standalone" before building and says so. That leaves the runner’s
working tree dirty — commit the change locally so CI has nothing to write, and
do not run git diff --exit-code after a deploy. A config setting
output: "export" is refused outright: that is a static site, so use
pb cloud frontend deploy.
No start command for this nodejs backend. (2)
Add a start script (or a start task for Deno), set build.startCommand in
pb.json, or pass --start "<command>".
34 hook files in db/pb_hooks — pb pushes at most 30 at a time. (2)
A CLI guard rail: a pb_hooks directory that large is nearly always the wrong
directory. Confirm the path in build.pbHooks. (The platform itself accepts
up to 50, so the portal is a way out if you genuinely need more.)
Skipped lib/ — the platform stores hooks as flat files…
Not an error, but files in a subdirectory of pb_hooks/ never reach the
instance — the missing require() shows up later as a runtime error. Flatten
them. Note that .js and .json files beside your *.pb.js hooks are
uploaded.
Provisioning
Timed out after 300s waiting for backend "api" (last status: creating). (5)
The resource exists and may still be provisioning. Check it with
pb cloud backend info --name api; remove it with … rm --name api --yes if
it is genuinely stuck. A healthy PocketBase reaches running in seconds — a
minutes-long creating is a platform problem, not slowness.
Exit 6 with no obvious message.
The resource settled on error/failed. Container logs have the reason:
pb cloud logs backend --name api --lines 200 (see recipe 6.4).
Subdomain "web" is already taken — pass a different --subdomain. (2)
Only when you passed --subdomain yourself. Left to choose, the CLI appends a
suffix and continues.
The deploy said “Not reachable yet”.
A new domain needs DNS and a certificate; the exit code stays 0 and the
resource is running. Under --json, check .reachable if your pipeline
depends on it being live.
Runner and packaging
pb: failed to run /usr/lib/node_modules/…/pb or pb: not found
An Alpine/musl container. Use a glibc image.
pb: no prebuilt binary for linux-x64.
The optional dependency was skipped — reinstall without --no-optional /
--ignore-optional.
JSON parse errors in a later step.
Something other than the final object reached stdout. Only --json guarantees
a clean stdout; tee/jq after a command without it will choke on the
progress lines. Also remember 2>&1 merges stderr back in — don’t.
A failed deploy that the job reported as green.
pb … | tee deploy.json returns tee’s exit status, and GitHub’s default
shell does not set pipefail. Redirect (> deploy.json) instead, or open the
step with set -o pipefail. Same trap with | head, | jq, and | tail.
The workspace is dirty after a deploy.
Deploying writes the resolved binding (and a Next.js backend’s
output: "standalone") into the checkout. Harmless on a throwaway runner, but
a git diff --exit-code step afterwards will fail. Commit those changes
locally instead of letting CI discover them.
9. Reference
Environment variables
| Variable | Effect |
|---|---|
PB_TOKEN |
Authenticates every pb cloud command; overrides a saved login |
PB_ENV |
Default --env for a whole shell/job |
PB_NO_UPDATE_CHECK |
Suppresses the once-a-day update notice (already off when CI is set) |
PB_INSTALL_DIR |
Where install.sh puts the binary |
XDG_CONFIG_HOME |
Moves pb’s config file ($XDG_CONFIG_HOME/pb/config.json) |
Flags a pipeline almost always wants
| Flag | Why |
|---|---|
--no-input |
Fail with a named cause instead of trying to prompt |
--json |
Machine-readable stdout, build output on stderr |
--yes |
Consent for rm and env import --delete-missing |
--name <n> |
Idempotent targeting without a committed pb.json |
--project <id> |
When no pb.json is committed |
--env <name> |
Which environment (or PB_ENV) |
--compute <id> |
Pro / org projects with more than one compute (create only) |
--skip-env |
Never touch the cloud env store from a deploy |
--skip-build |
Package what a previous step already built |
--zip <file> |
Upload an archive built elsewhere |
Pre-flight checklist
-
PB_TOKENset, fresh, and exposed to the job -
pbpinned to a version -
working-directorypoints at the resource’s own directory -
pb.jsoncommitted, or--project+--nameon every command -
--no-input --jsonon every command;--yeson destructive ones -
--computesupplied if the account has more than one - Environment variables handled (§5) — not silently skipped
- Output redirected, not piped, so the exit code survives
- A PocketBase deploy’s JSON never printed, echoed to an output, or
uploaded — it carries
adminPassword - A URL read out of
--jsonchecked for emptiness before it is used -
timeout-minutesset, so a wedged deploy cannot burn six hours - A
concurrency:group per deployed resource, andcancel-in-progressoff for production
See the CLI reference on GitHub for everything the CLI does outside CI, or Installing the CLI to get started.