Deploy on every push
Connect the repository once and a push is the deploy: Cloudflare runs the build, then voidbase sync deploys the instance, and nothing else runs. Every change to the instance is then a commit somebody can read, revert and blame, which is the whole reason to do it this way.
This works the same for a project and a stack app. Where they differ is noted as it comes up.
Before you start
- The project is in a GitHub repository, and you have pushed it at least once.
- You have deployed it by hand once, with
voidbase deploy, so the instance exists.
Neither is strictly required: sync deploys first and creates what does not exist. Doing the first deploy from your own machine means the first thing the pipeline ever does is an update rather than a creation, which is much easier to read when something is wrong.
Two tokens
One deploys the instance, one connects the repository. Both are yours, both are declared as local() keys, which is the tier that means read here and never deployed.
// pb_secrets/main.ts (vb_secrets/main.ts in a stack app)
import { defineSecrets, local, string } from "@voidbase-cloud/voidbase/secrets";
export default defineSecrets({
VOIDBASE_DEPLOY_CF_API_KEY: local(string(), "deploys the instance"),
CLOUDFLARE_BUILDS_TOKEN: local(string(), "connects the repository to Workers Builds"),
VOIDBASE_DEPLOY_NAME: local(string().default("blog-api"), "the Worker this repository deploys to"),
});voidbase token prints the link that creates the first, with the permissions already selected: Workers Scripts, D1, Workers R2 Storage and Queues edit, Account Settings read. The second is a user API token from your Cloudflare profile with Workers Builds Configuration: Edit and Workers Scripts: Edit. An account token will not do: the Builds API refuses it.
Their values go in the git-ignored file beside the declaration, and stay on your machine:
{
"VOIDBASE_DEPLOY_CF_API_KEY": "...",
"CLOUDFLARE_BUILDS_TOKEN": "..."
}Connect it
voidbase syncsync deploys the instance, then wires the repository to it. There is one step an API cannot do for you, installing Cloudflare's GitHub App on the repository, so the first run stops and points at it:
ci: the GitHub App is not installed for you/blog-api. One dashboard step: open https://dash.cloudflare.com/?to=/:account/workers/services/view/blog-api/production/settings/builds
Under Builds, connect you/blog-api: that installs the "Cloudflare Workers and Pages" GitHub App for it and creates the build token. Then run voidbase sync again.Do that, run the same command again, and the trigger is in place:
ci: you/blog-api -> Worker blog-api (account Example Ltd)
created trigger "blog-api (main)": a push to main runs `true`, then `bun run deploy`
build environment: BUN_VERSION, VOIDBASE_DEPLOY_CF_API_KEY (secret)
the pipeline: push to main and watch it at https://dash.cloudflare.com/?to=/:account/workers/services/view/blog-api/production/settings/buildsWhat it built
One trigger, on the branch you ran it from:
| On a push to | What happens |
|---|---|
| your production branch | The build command runs, then the deploy command. The Worker is replaced, and pb_migrations apply on the first request afterwards. |
| any other branch | Nothing. A branch can never replace what is live. voidbase sync --previews adds a second trigger that deploys every other branch as its own preview instance, which the project pipeline covers. |
The commands are the project's own verbs when it has them. voidbase init writes a package.json with deploy: "voidbase deploy" and no build script, so a scaffolded project's trigger runs true, then bun run deploy. A project without a deploy script gets the command spelled out, bunx @voidbase-cloud/voidbase sync --name blog-api, which in a build is the deploy alone: a build has no CLOUDFLARE_BUILDS_TOKEN and nothing to connect. Nothing about the pipeline lives in the dashboard; what those words do is decided by the repository, so it changes in a commit like everything else.
A Worker takes two triggers at most, and two triggers may not watch the same branch. That is the whole budget: production, and optionally previews.
Then just push
git add pb_migrations pb_hooks
git commit -m "posts: add a featured flag"
git pushThat is the loop from here. Watch it at the link sync printed, or in the Cloudflare dashboard under the Worker's Builds tab.
A stack app is the same
Run voidbase sync at the project root. On your machine it runs bun run build first, which is the Vite build with the adapter, and deploys from the instance the build generated into .voidbase/; --no-build skips that step. In a build the trigger's build command is bun run build and the deploy command deploys what it produced. The generated directory stays git-ignored: it is output, rebuilt on every build, and committing it would only create conflicts.
What a deploy creates, and the knobs on it
Every resource is named after the Worker, and two instances on one account never share one. Re-running is idempotent: what exists is reused, what changed is updated.
| Created | What it is | Knob |
|---|---|---|
blog-api-db | The D1 database. Free plan: 10 per account. | VOIDBASE_DATABASE=durable or --database durable keeps the data in a SQLite-backed Durable Object instead, and creates no D1. A batch is then one transaction; the 100-column and 100-parameter ceilings stay. |
blog-api-storage | The R2 bucket: uploaded files and backups. | |
blog-api-jobs | The queue: outbound mail and scheduled backups run from it with retries. | --no-queue; skipped by itself when the token lacks Queues edit |
| the Worker | The instance, with its realtime hub Durable Object inside it, its cron triggers, and Smart Placement on. | --no-hub keeps the D1 poll for realtime; --rate-limit 300/10 sets the per-IP ceiling on /api, 0 disables it |
| optional bindings | Workers AI for the chat route, Email Sending from the instance's own domain, an Analytics Engine data point per request. | VOIDBASE_AI=1, VOIDBASE_MAIL_DOMAIN=example.com, --analytics. Each is off unless set |
The knobs that are environment variables are read from pb_secrets/secrets.json as well as the shell, so they can be declared as local() keys and live beside the tokens. Some of them turn on a line of Cloudflare's bill that was not there before; the pricing page lists which.
Where it answers: a custom domain
voidbase sync --domain example.com,www.example.com # or VOIDBASE_DOMAINS in secrets.jsonThe hostnames are comma separated and the first is canonical. The deploy turns workers.dev off, attaches each hostname through the Workers Custom Domains API, where Cloudflare creates the DNS record and issues the certificate, waits up to 90 seconds for the certificate to be active, and sets a redirect rule per other hostname sending everything under it, 301, to the same path on the canonical one. The zone has to be on the same account. Reading the certificate's status takes SSL and Certificates Read on the deploy token, and writing the redirect rules takes Single Redirect: Edit on the zone; without either the deploy says so and carries on. Previews never get these hostnames: they stay on workers.dev.
More than one instance in one repository
A build may deploy only the Worker its trigger belongs to, so each instance needs its own trigger. Run voidbase sync inside each project directory and each gets one, with commands that step into that directory. Declare VOIDBASE_DEPLOY_NAME in each, which makes a deploy refuse to run against a Worker the project does not name, and turns the worst mistake available here into an error message.
Checking without changing anything
voidbase sync --dry-runPrints the plan and stops: which repository, which Worker, which branch, and the exact commands it would write. --no-ci deploys without touching the pipeline, --ci forces the pipeline part inside a build, and --repo owner/name and --branch override what git says. --previews adds the previews trigger and --no-previews removes it; a plain sync leaves an existing one as it is.
An existing trigger keeps the watch paths you set in the dashboard, so tuning which paths trigger a build is not undone the next time somebody runs sync.
Taking it down
voidbase deploy --remove # the Worker goes; the database, the bucket and the queue stay
voidbase destroy blog-api # everything the instance owns goes. Irreversible; asks first--remove runs every deploy plugin's remove hook first, so custom domains are detached and their redirect rules deleted, then deletes the Worker. The database, the bucket and the queue stay with everything in them, and a deploy afterwards finds them again. Both commands ask you to type the Worker's name unless --yes says nobody can be asked; --dry-run shows the plan.
Updating voidbase, tracked like everything else
A build installs what package.json says, so that file is what decides which voidbase a deploy carries. That is the useful property: an upgrade is a commit, it shows up in the diff, it deploys through the same pipeline as your own code, and reverting it is reverting a commit.
voidbase update --check # exits 1 when a newer release is out, 2 when it could not find out
voidbase update # bumps the dependency in package.json and installs it--check changes nothing and its exit code is what a pipeline reads: 1 when you are behind, 2 when it could not reach the registry, so a network problem is distinguishable from being out of date. Run voidbase update on your machine, look at the change to package.json and your lockfile, and push it. A pinned version never changes underneath you; a caret range picks up a new version on the next install, which is convenient until a build behaves differently from the one before it for a reason that is not in any commit.
Next
The one thing that must not be in the repository is the values of your secrets. What git tracks is the list, and how those values reach production without going through a commit.
Fix it yourself. The link below opens this file in GitHub's editor and forks the repository for you if you need one, and your change becomes a pull request without leaving the browser.