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 pbc 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 |
|---|---|
pbc login opens a browser |
No browser — authenticate with PBC_TOKEN |
| A prompt waits for you | Nothing answers it — --no-input |
pbc.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 PBC_TOKEN
PBC_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.
pbc login saves the same token to disk, so a shell one-liner works as
well:
jq -r .cloud.userToken ~/.config/pbc/config.json
(On Windows: %USERPROFILE%\.config\pbc\config.json. If XDG_CONFIG_HOME is
set, the file is $XDG_CONFIG_HOME/pbc/config.json.)
Store the value as a repository secret named PBC_TOKEN
(Settings → Secrets and variables → Actions → New repository secret), then
expose it to the job:
env:
PBC_TOKEN: ${{ secrets.PBC_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:
pbc 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 `pbc 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 $PBC_TOKEN. The JWT payload is base64url and unpadded,
# so translate the alphabet and pad it before decoding.
p=$(cut -d. -f2 <<<"$PBC_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 PBC_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
pbc never prints env var values, and --json output carries none — but your
own steps can. Never echo "$PBC_TOKEN", never set -x around a curl carrying
it, and prefer env: over interpolating ${{ secrets.PBC_TOKEN }} directly
into a run: script.
2. Install pbc on the runner
The official action — on GitHub Actions, skip this section entirely
pocketbasecloud/cli/action installs pbc, verifies the token, deploys, and
publishes masked outputs, all in one step — see
Deploying from GitHub Actions and
the action’s own README.
Everything below this point — the raw install methods, --json parsing,
masking a PocketBase deploy’s admin credentials by hand — is what the action
does for you. Reach for it when you are not on GitHub Actions (the action is
Actions-specific; the CLI itself is not), or when a recipe below needs a step
the action doesn’t expose yet.
npm, pinned (recommended)
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g @pocketbasecloud/[email protected]
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/[email protected] frontend deploy --no-input --json
curl | sh (no Node)
- name: Install pbc
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.
PBC_INSTALL_DIR: ${{ runner.temp }}/pbc-bin
run: |
curl -fsSL https://raw.githubusercontent.com/pocketbasecloud/cli/main/scripts/install.sh | sh
echo "$PBC_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 (pbc: 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, andpbcthen 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
pbc 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 PBC_NO_UPDATE_CHECK=1.
3. Make every command non-interactive
pbc frontend deploy --no-input --json
--no-input— fail with a usage error instead of prompting. A runner has no TTY, sopbcalready 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, warnings) on stderr, sopbc … --json | jqis safe. The CLI’s own upload and provision progress is silent under--json; a subprocess build still writes to stderr. 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 pbc.json |
| Which resource / what to name it? | --name <name> (or a committed binding) |
| Which environment? | --env <name> or PBC_ENV; default production |
| Which compute? (Pro, or an org project with several) | --compute <id> — see pbc compute ls |
| Which dotenv file to push? | --env-file <path>, --skip-env, or the envFile recorded in pbc.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
pbc.json — so a later interactive deploy still gets asked.
4. Tell CI what to deploy
Commit pbc.json (the easy path)
pbc.json binds a directory to a cloud resource. Deploy once from your laptop,
commit the file, and CI needs no flags at all:
// web/pbc.json
{
"projectId": "dhs4xnprgplurvo",
"kind": "frontends",
"defaultEnvironment": "production",
"environments": {
"production": { "id": "…", "name": "web" }
},
"build": { "command": "npm run build", "outputDir": "dist" }
}
- run: pbc 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:
pbc 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
pbc.json. It is thrown away with the workspace — but agit diff --exit-codestep after a deploy will notice it.
pbc deploy in a pipeline
pbc deploy detects the kind from the directory and runs
pbc pocketbase deploy, pbc frontend deploy, or
pbc backend deploy — see
One deploy command for all three
for the rules. It is safe in CI with one caveat worth stating plainly:
- With
pbc.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
pbc.jsonor name the kind —pbc 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
pbc.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
pbc.json also records exactly one kind per directory. Deploying a backend
from a directory bound to frontends fails with
pbc.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 PBC_ENV for a whole job:
env:
PBC_ENV: staging
--env naming an environment pbc.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 > PBC_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
pbc.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
pbc.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:
pbc 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 pbc 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: pbc 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. pbc’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 pbc.json.
c) Import separately, when you want the file to be the whole truth:
pbc 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: pbc 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
pbc.jsonis committed, so the project resolves without a flag. If it is not, add--project <name|id>to every command. - 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.
pbc ci init writes 6.1 for you — run it in the resource directory
and it generates exactly this, wired to the official action. The recipes below
show what it writes, and the hand-rolled form underneath each one for anyone
who wants their own build/cache step instead of the CLI’s built-in one.
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
steps:
- uses: actions/checkout@v4
- uses: pocketbasecloud/cli/[email protected]
with:
token: ${{ secrets.PBC_TOKEN }}
working-directory: web
kind: frontend
pbc installs dependencies itself when something package.json declares is
missing, so this needs no separate npm ci/build step — the action’s own
pbc frontend deploy call does it, from the build block pbc init
already inferred into web/pbc.json.
Without the action — if you want your own npm cache step
name: Deploy web
on:
push:
branches: [main]
paths: ["web/**", ".github/workflows/deploy-web.yml"]
workflow_dispatch:
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:
PBC_TOKEN: ${{ secrets.PBC_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/[email protected]
# Fails fast with exit 4 on a missing or expired token, before any build.
- run: pbc 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: pbc 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"
pbc installs dependencies itself when something package.json declares is
missing, so the npm ci step here is optional too — it is there for the
lockfile guarantee and the cache. This form is also the one to reach for on
CI systems other than GitHub Actions, since the action itself is
Actions-specific.
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:
PBC_TOKEN: ${{ secrets.PBC_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.
pbc pocketbase 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
pbc 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
pbc 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 pbc 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:
PBC_TOKEN: ${{ secrets.PBC_TOKEN }}
PBC_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/[email protected]
# --name OVERRIDES a committed binding, so it has to equal the name
# pbc.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: |
pbc frontend deploy \
--name "web-$PBC_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:
PBC_TOKEN: ${{ secrets.PBC_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/[email protected]
# 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: |
pbc 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: pbc 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: pbc logs backend --name my-app-api --lines 200 || true
6.5 Scheduled backup
The CLI has no data-export command — a backup is the way to pull a full copy of
an instance’s database out. It is a superuser operation on the instance, so this
job authenticates with pbc admin login (the instance’s superuser email and
password as secrets), not with a PBC_TOKEN.
name: Nightly backup
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:
backup:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm i -g @pocketbasecloud/cli
- name: Download a fresh backup
env:
PBC_ADMIN_EMAIL: ${{ secrets.PBC_ADMIN_EMAIL }}
PBC_ADMIN_PASSWORD: ${{ secrets.PBC_ADMIN_PASSWORD }}
run: |
pbc admin use https://my-app-db.pocketbasecloud.com --no-input
pbc admin login --email "$PBC_ADMIN_EMAIL" --password "$PBC_ADMIN_PASSWORD"
KEY="nightly-$(date -u +%F).zip"
pbc admin settings backup create "$KEY"
pbc admin settings backup download "$KEY" --out data.zip
- uses: actions/upload-artifact@v4
with:
name: pocketbase-backup
path: data.zip
retention-days: 7
Two things about this one that bite later, neither of them about pbc:
- 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.
(The CLI has no import command — restoring a backup is a superuser action in the instance’s admin panel, and collection-level imports need 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 | PBC_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
pbc frontend deploy --name web --no-input --json > deploy.json
code=$?
set -e
case $code in
0) ;;
5) echo "::warning::Still provisioning — check pbc 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 'pbc login'. (4)
PBC_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: PBC_TOKEN is set and takes precedence …
Harmless in CI. It only appears if something in the job also ran
pbc 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
pbc 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 'pbc project use'. (2)
No pbc.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 pbc.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 pbc.json.
Multiple frontends named "web". Pass --id. (2)
Two resources share the name. Use --id, and rename one.
pbc.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 pbc.json. Set --env/PBC_ENV, or put
the key back.
Unknown environment "prod". Configured: production, staging. (2)
A typo in PBC_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
pbc 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
pbc 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)
pbc 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 pbc.json.
Env file not found: .env.production (2)
pbc.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
pbc frontend deploy.
No start command for this nodejs backend. (2)
Add a start script (or a start task for Deno), set build.startCommand in
pbc.json, or pass --start "<command>".
34 hook files in db/pb_hooks — pbc 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
pbc 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:
pbc logs backend --name api --lines 200 (see recipe 6.4).
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.
pbc … | 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 |
|---|---|
PBC_TOKEN |
Authenticates every command that talks to your account; overrides a saved login |
PBC_ENV |
Default --env for a whole shell/job |
PBC_NO_UPDATE_CHECK |
Suppresses the once-a-day update notice (already off when CI is set) |
PBC_INSTALL_DIR |
Where install.sh puts the binary (PBC_INSTALL_DIR still works) |
XDG_CONFIG_HOME |
Moves pbc’s config file ($XDG_CONFIG_HOME/pbc/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 pbc.json |
--project <id> |
When no pbc.json is committed |
--env <name> |
Which environment (or PBC_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
-
PBC_TOKENset, fresh, and exposed to the job -
pbcpinned to a version -
working-directorypoints at the resource’s own directory -
pbc.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.