Submit an app
Add an app to the Appflare catalog with one manifest file and a pull request.
Adding an app takes one file, apps/<slug>/appflare.jsonc, in a pull request to
appflare/catalog. You do not need to change the
app's own repository.
Before you start
- The app needs a public repository. Its license does not decide whether it is
listed: the catalog takes any app the platform can run, open-source,
source-available, or with no license at all, and shows the license as the
repository declares it. See
licensebelow. - No proxies, tunnels, or circumvention tools.
- It deploys with wrangler. If the app works with Cloudflare's Deploy button, it
should work with Appflare. Bindings, compatibility settings, static assets, Durable
Object migrations, queue consumers, and cron triggers all come from the app's own
wrangler config; the manifest does not repeat them. An app that needs a build step
before
wrangler deploy(Vite, React Router, OpenNext) and has nobuild.commandin its wrangler config names that step ininstall.buildCommand. The config's ownbuild.commandruns in the config's directory, as it does when you runwrangler deploybeside the config, so a command such asnpm run buildfinds thepackage.jsonnext to it. Each module reaches the Worker as the typewrangler deploywould upload it as, following the config'srulesand wrangler's default ones: aTextrule for**/*.svgmakes an imported SVG a string, not bytes. - It is self-contained. Durable Objects and Workflows must be defined by the app's
own Worker (or, in an app of several Workers, by another Worker of the same entry),
not bound from a Worker outside the app. The only service binding an app may have
is one to its own Worker (its
serviceis the wrangler config's ownname, as OpenNext'sWORKER_SELF_REFERENCEis), optionally with anentrypoint. A service binding to any other Worker fails the pack, because an app must never be able to call another app or Appflare itself. An app made of several Workers lists them all in its entry; see Apps of several Workers. - Each Worker fits one upload. The manager installs apps from inside a Workflow on the free plan, which allows 50 subrequests per invocation, and uploads each Worker in one request. It reads modules that lie next to each other in the artifact with one Range request per 8 MiB, so a build may emit as many modules as it likes, but a Worker's modules may add up to at most 32 MiB: the upload holds all of them in memory at once.
- It fits the free plan, or says it does not. An app that needs paid features
is
"plan": "paid". Size does not decide the plan: Cloudflare accepts a Worker of up to 64 MiB uncompressed on every plan, and Appflare installs a Worker whose modules add up to at most 32 MiB on either.
The manager creates KV namespaces, D1 databases, R2 buckets, queues, Vectorize
indexes, and Pipelines streams with their sinks and pipelines for an app, attaches the app's Worker to the queues it consumes (dead-letter
queues included), and passes through Workers AI, Browser Rendering, Analytics Engine,
email sending (with its address restrictions), rate limits, Images, version metadata,
and plain variables. A rate limit declared the older way, in unsafe.bindings with
"type": "ratelimit", is installed like one in ratelimits; every other unsafe
binding fails the pack, since Appflare cannot tell what it needs. Each install gets
rate limit counters of its own. A service binding to the app's own Worker is pointed
at the installed Worker, whatever name it is installed under; set
install.fixedWorkerName only when something else in the app needs one fixed name.
Other binding types cannot be installed yet: a wrangler config that declares one
(Workers VPC services, Secrets Store secrets, Tail Workers, dispatch namespaces, mTLS
certificates, Containers, AI Search, Media, Stream, inbound email addresses, Workers
Sites, and the rest wrangler knows) fails the pack with a message naming it, rather
than installing an app that would run without it. When the app works without that
section, drop it with the config patch the message names, such as
{ "vpc_services": null }. An mTLS certificate is uploaded to one account with its
private key, so an artifact cannot bring one. A key at the top of the config that the
packer's wrangler does not know (a binding added in a newer wrangler, or a key
wrangler never had) fails the pack the same way, since wrangler would drop it with no
more than a warning; drop it with the config patch the message names when the app
works without it.
Each Workflow is created for the Worker that defines it with the settings its
workflows entry gives it, limits, concurrency, schedules and
default_retention, as wrangler deploy sends them. Updates and rollbacks send the
settings of the version they deploy: Cloudflare puts a limit, concurrency or retention
that version leaves out back to its default for instances started once the change
takes effect, and a version without schedules is sent with an empty list of them. A
Workflow the deployed version no longer defines stays until the app is uninstalled,
but Appflare takes off the schedule an earlier version gave it, since each run would
fail. A binding that runs another Worker's Workflow cannot set any of these; wrangler
refuses that too. Cloudflare runs a Workflow on a schedule only on Workers Paid, so an
app with schedules needs "plan": "paid". On Workers Free it also takes at most
1,024 steps and 100 running instances, and refuses the Workflow when the settings ask
for more.
An app that needs every name under one hostname, such as a tunnel that gives each
session <id>.<hostname>, sets install.wildcardHostname with a one-sentence
reason that the admin sees when assigning the hostname, such as
"wildcardHostname": { "reason": "Each tunnel gets its own address under this hostname." }. Appflare
then serves the app's Worker on the hostname the admin chooses and every name under
it, through Workers routes in one of the account's domains (see
Wildcard domains). An app that needs its
hostname in a variable uses {{wildcardHostname}}, for example
{ "name": "TUNNEL_DOMAIN", "label": "Tunnel domain", "default": "{{wildcardHostname}}" }.
An app whose data lives in a PostgreSQL or MySQL database outside Cloudflare, reached
through Hyperdrive, is installed too: its manifest declares the database (see
Databases elsewhere), and the admin enters its connection
string at install. The catalog's install check has no
database of the app's own to connect to, so it skips an app with Hyperdrive bindings
and says so, unless the catalog repository has a HYPERDRIVE_TEST_URL Actions secret
holding a connection string to a throwaway test database; then it installs the app
against that database. The check does not support mTLS certificate bindings yet: it
fails with a message naming the binding, so such an app cannot pass its pull request
checks. Queues, email sending, and a service
binding to the app's own Worker are deployed as the manager would deploy them; the
check points a self binding at the Worker it deploys. A service binding to any other
Worker already fails the pack.
1. Write the manifest
Create apps/<slug>/appflare.jsonc. The folder name must equal slug. A complete
example:
{
"$schema": "https://appflare.github.io/catalog/schema/v1.json",
"slug": "cut",
"name": "Cut",
"summary": "Self-hosted link shortener on Workers + KV.",
"tagline": "Short links on your own domain",
"repo": "MendyLanda/cut",
"license": "MIT",
"categories": ["utilities"],
"authors": [{ "name": "Mendy Landa", "github": "MendyLanda" }],
"maintainers": ["MendyLanda"],
"source": { "ref": "v0.1.0", "sha": "6056400d47530aa87e4ae5764b37ffca9d00e87f" },
"install": {
"packageManager": "pnpm",
"wranglerConfig": "wrangler.jsonc"
},
"plan": "free",
"secrets": [
{
"name": "ADMIN_PASSWORD",
"label": "Admin password",
"help": "Used to sign in to /admin.",
"generate": "password"
}
],
"vars": [
{
"name": "HOME_PAGE",
"label": "Home page",
"help": "What / shows. Short links and /admin work the same either way.",
"type": "select",
"options": [
{ "value": "default", "label": "Landing page" },
{ "value": "404", "label": "Empty 404" },
{ "value": "admin", "label": "Redirect to /admin" }
],
"default": "default"
}
],
"postInstall": [
{
"type": "markdown",
"content": "Open {{appUrl}}/admin and sign in with the admin password to create your first link."
}
]
}A field you leave out takes its default: install.tier is "artifact",
install.workerName is the slug, homepage is the app's repository on GitHub, and
requires, secrets, vars, postInstall and tokenPermissions are empty lists. Leave them out
unless the app needs something else. The packer and the catalog checks refuse a field
the schema does not know, so a misspelled field is an error rather than a setting that
is quietly ignored; for a field that has moved, the message names its new place.
Points that need care:
-
tagline. Required. What the app does, as one plain sentence of at most 80 characters without a trailing period, such as"Short links on your own domain". Managers show it under the app's name on catalog tiles. Write it for someone who is not a developer. -
categories. One to three categories the catalog lists the app under, each once, by id from this fixed list:ai(AI),analytics(Analytics),bots(Bots),business(Business),chat(Chat),cms(Websites and blogs),community(Community),developer-tools(Developer tools),ecommerce(E-commerce),education(Education),email(Email),family(Family),files(Files),finance(Finance),games(Games),marketing(Marketing),media(Media),monitoring(Monitoring),networking(Networking),notes(Notes),notifications(Notifications),passwords(Passwords),privacy(Privacy),productivity(Productivity),remote-access(Remote access),scheduling(Scheduling),security(Security),sharing(Sharing),sync(Sync), andutilities(Utilities). Managers show each category by its label. -
license. Write the license the app's own repository declares, as an SPDX license expression of ids from the current SPDX License List:MIT,Apache-2.0,GPL-3.0-only,MIT OR Apache-2.0, or a source-available license such asBUSL-1.1,FSL-1.1-MIT,PolyForm-Noncommercial-1.0.0orElastic-2.0. Deprecated ids are refused: writeGPL-3.0-onlyorGPL-3.0-or-lateras the project's license notice says ("or any later version" means-or-later), neverGPL-3.0. UseNONEwhen the repository publishes no license. For a license with no SPDX id, writeLicenseRef-<name>, such asLicenseRef-Acme, and describe it inlicenseNote.NOASSERTIONandSEE LICENSE IN <file>are refused in a catalog entry: Appflare writes them only for an app built from a repository without one. AddlicenseNote, one short line such as"Source-available: production use restricted; see the license", when the id does not say what matters. Managers show the license on the app's card and page:NONEas No license, and source-available licenses (and any license with a note) marked Source-available. It is never a reason to leave an app out. -
source. Setshato the full 40-character commit SHA, andrefto the tag it belongs to (v1.2.3) or the branch (main) for an untagged app. A semver tag gives the version1.2.3; anything else gives0.0.0-<commit date>.<sha7>. -
source.version. Only for repositories whose tags do not describe this app, such as a monorepo of templates. It overrides the version and must change whenevershamoves. -
install.health. How the health check reads the app, for example"health": { "path": "/api/health" }. Setpathwhen/returns an error or needs a login, so the check requests a path that answers; it defaults to/.modedefaults to"no-server-errors": any answer but a server error (5xx) passes. Set it to"any-response"when every route of the app, its health path included, asks for a sign-in, its own or one it checks from Cloudflare Access. Such an app can answer the check with an error of its own, and any answer from its Worker then counts as healthy. See Health checks. -
openPath. Optional. When the app's interface is not at its root, such as an admin dashboard at/dashboard, set"openPath": "/dashboard"and the manager's Open buttons go there. It is a path only: it starts with/, has no query or fragment, and may end in/. Health checks,{{appUrl}}and the addresses the app's page lists stay the root, so apostInstallnote that links to the dashboard writes{{appUrl}}/dashboard. A manager from before this field opens the root. -
install.buildCommand. One command, such aspnpm --filter @scope/web build, that the packer runs at the root of the repository after installing dependencies and before bundling, or a list of up to eight such commands run in order, such as["pnpm run build:sphere", "pnpm run build"]. Each runs without a shell and without credentials, with the repository'snode_modules/.binon its PATH, so pipes, redirects, quotes, variables, andNAME=valueassignments are refused. pnpm and npm run nopreorposthooks there (pnpm run buildskipsprebuild), just as dependencies install with--ignore-scripts, so list such a step as a command of its own. The build stops at the first command that fails. A build whose commands all exit 0 without creating, changing or removing a single file in the repository built nothing (a tool given an option where it does not take one may print its usage and exit 0), and the pack fails with its output. -
install.buildEnv. Public constants the build compiles into the app, by name, such as{ "VITE_API_ORIGIN": "https://api.example.com" }for a Vite or SvelteKit app that reads settings at build time. The packer sets them for every build command and for wrangler's bundling, and the artifact records them. Everyone can read them, since the catalog publishes them and the build writes them into files anyone can download, so a name that looks like a credential (containingSECRET,PASSWORD,PASSWD,PASSPHRASE,TOKEN,PRIVATEorCREDENTIAL) is refused. So is a name that would change how the build runs rather than what it writes: those read by wrangler and what it runs (WRANGLER_,CLOUDFLARE_,CF_,ESBUILD_,MINIFLARE_,WORKERD_), Node.js and package managers (NODE_,NPM_,PNPM_,YARN_,BUN_,COREPACK_,DENO_), git and CI (GIT_,GITHUB_,RUNNER_,ACTIONS_,CI), shells (BASH…,ENV,IFS,PS4,PROMPT_COMMANDand the like), the dynamic linker (LD_,DYLD_,GCONV…), TLS and HTTP clients (SSL_,OPENSSL_,CURL_,HTTP_PROXYand the other proxy names), configuration directories (XDG_,HOME,TMPDIR), other toolchains (PYTHON…,PERL5…,RUBY…,JAVA_,CARGO_,RUST…,GOPATH,GOFLAGSand the other Go settings) and build tools (TURBO_,NX_,PRISMA_). These rules catch the known cases; catalog review reads every constant as well. Up to 32 upper-case names. -
install.installDirs. The directories whose dependencies the packer installs, in order, such as[{ "path": "templates/blog" }]for a template repository whose Worker has its ownpackage.jsonand no root one. Omitted, only the root is installed; list"."as well when the root still needs its install. Each entry may name itspackageManager; without one, a directory usesinstall.packageManagerwhen it holds that manager's lockfile or none, and otherwise the one its lockfile names. Set"lockfile": "none"only when upstream ships no lockfile for that directory: the packer then resolves the dependencies itself, still with--ignore-scripts, and prints the sha256 of the lockfile it wrote. A directory that holds a lockfile always installs from it, and any other directory needs one in it or above it (a workspace's), or the pack fails. For a repository without apackage.json, set"installDirs": []to install nothing: wrangler still bundles the entry and every file it imports by a relative path, an import of a package fails the pack, and any build command runs with no dependencies installed."lockfile": "none"is no substitute there: pnpm and bun refuse a directory without apackage.json, and npm looks for one in the directories above the checkout. Set"devDependencies": falseon a directory whose devDependencies cannot be installed (a package from a private registry, say) and are not needed to bundle the Worker: it installs with pnpm--prod, npm--omit=dev, or classic yarn and bun--production, still from the lockfile. yarn 2 and later have no such install, so the pack fails there. -
install.configPatch. When the app's wrangler config needs a change before it installs from its pinned commit, open a pull request upstream first. Until it is merged, the entry may carry the change as a JSON merge patch (RFC 7386: an object merges key by key,nullremoves a key, an array replaces the whole list), with a comment linking the pull request:"install": { // Until https://github.com/<owner>/<repo>/pull/<number> is merged. "configPatch": { "build": null, "vars": { "DEBUG": null } } }After the build commands, the packer writes the patched config beside the original as
.appflare.wrangler.jsonc, so relative paths resolve as before, bundles from it, and prints each change in the pack log. A TOML config is patched the same way and written as JSONC. A build command that reads the wrangler config itself still sees it unpatched. When the build generated the config that gets deployed (the Cloudflare Vite plugin'sdist/<name>/wrangler.json, whichinstall.wranglerConfigleads to through the build's redirect), the patch applies to that generated config instead, after the build, and the patched copy is written beside it: adding a rate limit or a binding works the same, butmain,assets.directoryandbuildof a generated config are the build's, so a patch that sets them fails the pack. Only these keys may be patched:mainandassets, with paths relative to the config and without..;build, only tonullwheninstall.buildCommandbuilds instead;services, only to leave bindings out or to add one that points at a Worker of the same entry, optionally withprops(see below);kv_namespaces,r2_bucketsandd1_databases, only to add bindings or to leave out anid,bucket_nameordatabase_idthat is an empty string or a placeholder the upstream deploy script fills ($NAME,${NAME},{{NAME}},<NAME>), so the install provisions it;vars, to set a var to text or remove it withnull(see below);migrations, only to renamenew_classestonew_sqlite_classes, which the Free plan requires;ratelimits, only to add a rate limit an upstream deploy script adds, keeping the config's own;ai, only{ "binding": "<NAME>" }, to add the Workers AI binding an upstream deploy script adds to a config that has none. A section Appflare cannot install may be set tonullto drop it, when the app works without it ("vpc_services": null,"unsafe": null), and so may a key the config sets that the packer's wrangler does not know ("email": null). Anything else fails with a message. An app of several Workers setsconfigPatchon each Worker ininstall.workers. The patch is part of the signed manifest.appflare-pack inspect <checkout> --config <config> --manifest appflare.jsoncapplies it and shows what changed.A var a patch sets is part of that Worker's config, signed with the release: the admin does not see it in the app's settings. Use it for a value one Worker of an app of several Workers needs for itself, such as the address it is reached at. Its text may hold the placeholders a var's
defaulttakes, filled in at every install, update and settings change, and again when the app's address changes:{ "name": "github", "wranglerConfig": "packages/gatekeeper-github/wrangler.jsonc", "configPatch": { "vars": { "BASE_URL": "{{appUrl}}/gatekeeper/github" } } }A patched var may not have the name of a secret its Worker gets, nor of a catalog var that goes to that Worker. A value the admin chooses belongs in
varsinstead. An entry whose patch sets var text or adds anaibinding lists"config-patch-values"inrequires. The admin sees these vars read-only in the app's settings with Show technical names on.A service binding in a patch may carry
props, the JSON object the Worker it points at reads asctx.propson every call through it, aswrangler deploysends it. Its strings take the same placeholders:{ "binding": "CONTEXT", "service": "context", "entrypoint": "Vendor", "props": { "sharingDomain": "{{appUrl}}" } }. Props a wrangler config sets on a binding to its own Worker, or to another Worker of the entry, are kept the same way. An entry with props lists"service-props"inrequires. -
install.wranglerConfigInline. When the repository commits no wrangler config at all (its deploy script writes one, or it relies on wrangler's automatic setup), open a pull request upstream that adds one. Until it is merged, the entry may carry the config itself, with a comment linking the pull request, and setwranglerConfigto where the packer writes it:.appflare.wrangler.jsoncat the root, or<directory>/.appflare.wrangler.jsoncfor a Worker that lives in a directory of the repository. Relative paths in the config resolve from that directory."install": { "wranglerConfig": ".appflare.wrangler.jsonc", // Until https://github.com/<owner>/<repo>/pull/<number> is merged. "wranglerConfigInline": { "main": "server/src/index.ts", "compatibility_date": "2026-01-20", "assets": { "directory": "./dist/client", "binding": "ASSETS" }, "d1_databases": [{ "binding": "DB" }], "triggers": { "crons": ["*/20 * * * *"] } } }The packer writes it before installing dependencies, with the install's Worker name as
name, and reads it like a config of the repository's. It may set onlymain,compatibility_date(required),compatibility_flags,assets,vars,triggers,observability,placement,kv_namespaces,r2_bucketsandd1_databaseswithout ids (the install provisions each),queues,durable_objectsbindings to classes its ownmigrationscreate innew_sqlite_classes,workflowsandserviceswithout ascript_name, and theai,browser,imagesandversion_metadatabindings. A service binding to another Worker of the entry names it as the install does: the primary Worker byinstall.workerName, any other as<workerName>-<name>. The pack fails when the repository has a config of its own in that directory (patch that one instead), or when a build leaves a redirect to a config it generated there. The config cannot sit beside aconfigPatch; an app of several Workers sets it on each Worker ininstall.workers. The packer never runs wrangler's automatic configuration (whatwrangler deploydoes in a project without a config): it changespackage.jsonand adds dependencies, so the install would no longer match the lockfile. -
Package manager versions. The packer reads which version the repository expects from its
package.json(the nearest one at or above each install directory). yarn 2 or later must be pinned with"packageManager": "yarn@4.x.y"; it installs withcorepack yarn install --immutable --mode=skip-buildandYARN_ENABLE_SCRIPTS=false, since yarn 2 refuses classic yarn's flags, and corepack fetches the pinned yarn. A repository with a.yarnrc.ymlbut no such pin fails the pack: corepack would run classic yarn, which would run install scripts. npm installs with the npm that ships with Node.js 22 (npm 10), unless"packageManager": "npm@11.x"orengines.npmasks for a later major; npm 11 runs asnpx --yes npm@11.20.0 ci --ignore-scripts, an exact release. Apackage-lock.jsonoflockfileVersion3 that npm 10 refuses as out of sync ("Missing: … from lock file") or cannot resolve (ERESOLVE), as it does with some lockfiles npm 11 wrote, is installed again with npm 11, and the log says so. When nopackage.jsonnames an npm but.nvmrcorengines.nodeasks for Node.js 24 or later, which ships npm 11, the install uses npm 11 from the start. Apnpm-lock.yamloflockfileVersion6 (pnpm 8), which pnpm 10 refuses, installs with pnpm 9 asnpx --yes pnpm@9.15.9 install --frozen-lockfile. The build itself still runs on Node.js 22. -
What runs at install. Install scripts are off, but that does not keep the repository's own code out of the install:
npxcan resolve a binary from the checkout'snode_modules, and ayarnPathin.yarnrc.ymlruns the yarn release file committed to the repository. The build commands run the repository's code anyway, which is why the pack runs without credentials. -
install.toolchains. Set["rust"]for a workers-rs app whose build runsworker-build: catalog CI then installs a pinned Rust toolchain with thewasm32-unknown-unknowntarget before the pack. Only theartifacttier takes it; an app that needs Rust cannot be built in an account's sandbox Worker, whose image has no Rust. -
Worker size.
pnpm pack-appprints each Worker's modules, the Range requests the manager reads them with, and their size, for example579 modules in 2 ranges, 11.44 MiB of at most 32.00 MiB. Cloudflare accepts a Worker of up to 64 MiB uncompressed on every plan; the manager uploads at most 32 MiB of modules in one request, and packing fails above that. The number of modules is not limited. -
install.wranglerConfig. Name the app's own wrangler config, the one you would runwrangler deploynext to. When the build leaves.wrangler/deploy/config.jsonbeside it, as the Cloudflare Vite plugin does, the packer follows that redirect to the config the build generated, exactly aswrangler deploydoes, and records both paths in the artifact. A generated config named directly also packs (the packer dropslegacy_env, which older versions of the plugin write and wrangler accepts only through the redirect), but then relative paths in it, such as a D1 binding'smigrations_dir, resolve from the build's output directory, so prefer the app's own config. When the repository keeps its config only as a template, such aswrangler.toml.exampleorwrangler.jsonc.template, name the template: the packer copies it to its real name (wrangler.toml) beside itself before the build runs and wrangler reads it. A real file of that name already in the repository must be identical to the template, or the pack fails. -
Vectorize indexes and R2 buckets. The wrangler config cannot say how to create a Vectorize index, so declare each binding's shape under
resources.vectorize, with the metadata properties the app's queries filter on, if any:"resources": { "vectorize": { "VECTORIZE": { "dimensions": 768, "metric": "cosine", "metadataIndexes": [{ "propertyName": "url", "type": "string" }] } } }. The manager creates the metadata indexes (at most ten) right after the index, before the app writes a vector, since vectors written earlier are never indexed. An R2 bucket the app keeps files in only for a while can carry lifecycle rules underresources.r2, keyed by the R2 binding:"resources": { "r2": { "FILES": { "lifecycle": [{ "id": "Delete temporary files", "prefix": "tmp/", "deleteAfterDays": 7 }] } } }. A rule takesdeleteAfterDays,infrequentAccessAfterDaysorabortMultipartUploadsAfterDays(any of them, in whole days), and applies to every object when it has noprefix. The manager sets the rules when it creates the bucket and keeps Cloudflare's default rule (Default Multipart Abort Rule, which aborts unfinished multipart uploads after seven days), so that id is refused for a rule of the app's own. On the bucket, each rule's id starts withappflare:(appflare:Delete temporary files), which keeps it apart from rules added by hand; a declared id is at most 55 characters, so that the id on the bucket stays within 64. A later version's settings also reach an index or bucket an earlier version created. An update creates the metadata indexes the index lacks before the new version runs, and warns that vectors written before then are found by a filter on that property only once the app writes them again. It merges the declared rules into the bucket's by id, replacing only its ownappflare:rules, and only once the new version serves: an update that fails before then leaves the bucket's rules as they were, so a rule that deletes objects never reaches a bucket the previous version still uses. An update never deletes a metadata index or removes a rule, not even one a later version no longer declares; its log names such a rule, which the admin can delete in the bucket's settings. A rollback does not change a bucket's rules either: the rules of the version it leaves stay and keep deleting or moving objects, and the rollback's log names each rule the version it returns to does not declare. -
Databases elsewhere. An app that keeps its data in PostgreSQL or MySQL outside Cloudflare binds it through Hyperdrive. Declare each Hyperdrive binding of the wrangler config under
resources.hyperdrive, keyed by the binding, for example"resources": { "hyperdrive": { "HYPERDRIVE": { "protocol": "postgres", "label": "Main database" } } }, with an optionalhelpsentence. The install form then asks for a connection string per binding (postgres://user:password@host:5432/database, ormysql://), and the manager creates a Hyperdrive configuration of the install's own from it, named<worker name>-<binding>. The packer refuses a Hyperdrive binding the manifest does not declare, and a declaration the config does not bind. The database is the admin's: an uninstall deletes the Hyperdrive configuration, never the database. Self-deploying entries cannot declare databases.Hyperdrive caches the results of read queries by default. For an app that must read its own writes at once, set
"caching": falseon the binding, and list"hyperdrive-caching"inrequires:"HYPERDRIVE": { "protocol": "postgres", "caching": false }. The manager makes the configuration with caching off, and every update and settings change sets it back to what the entry says if it changed."caching": trueturns it on the same way. Without the field, the configuration keeps Cloudflare's default when it is made, and updates leave the setting alone. -
Receiving email. An app with an
emailhandler that should receive mail at the admin's domain setsinstall.emailRouting: the addresses it wants inrules(a local part such asinbox, or a full address), and"catchAll": truefor every other address of the domain, for example"emailRouting": { "rules": ["inbox"], "catchAll": true }. The install form asks for a domain of the account and Appflare sets up Email Routing there; see Apps that receive email. An app of several Workers names the one that receives the mail withworker(see Apps of several Workers). Not for the self-deploying tier. -
Streams (Pipelines). A stream's id in the wrangler config (
"pipelines": [{ "binding": "EVENTS", "stream": "<id>" }]) belongs to the author's account, so describe the stream underresources.pipelines, keyed by the binding:"resources": { "pipelines": { "EVENTS": { "schema": { "fields": [{ "name": "ts", "type": "timestamp", "required": true }] }, "sink": { "type": "r2_data_catalog", "bucket": "WAREHOUSE", "namespace": "app", "table": "events", "tokenSecret": "CATALOG_TOKEN", "rollIntervalSeconds": 60, "compaction": true } } } }. The manager creates the stream (<worker_name>_<binding>_stream, schema as given, no HTTP endpoint), an R2 Data Catalog sink that writes tonamespace.table, and a pass-through pipeline (INSERT INTO <sink> SELECT * FROM <stream>), and binds the stream.bucketnames an R2 binding of the app, or any other name for a bucket of the install's own (<worker name>-<name>), which no Worker binds; a var such as"{{workerName}}-warehouse"tells the app its name.tokenSecretnames a secret the form asks for (nogenerate,optional,deriveorseedOnly): an R2 API token with Admin Read & Write, which Cloudflare keeps as the sink's credential and which the app may also use for R2 SQL.compactionandsnapshotExpiration({ "maxAge": "30d", "minSnapshotsToKeep": 5 }) turn on the catalog's table maintenance. Pipelines is on Workers Paid only, so the entry needs"plan": "paid". Streams, sinks and pipelines cannot be changed once created: an update to a version that changes the schema or the table is refused, so that version needs a fresh install. A version that adds a stream gets it on update, which asks for the token intokenSecretagain. The packer refuses a Pipelines binding the manifest does not describe, and a description the config does not bind. Self-deploying entries cannot declare streams. -
D1 SQL outside the migrations folder. The packer reads each D1 binding's
migrations_dirandmigrations_patternaswrangler d1 migrations applydoes, relative to the config you name ininstall.wranglerConfig. When the app's D1 SQL is elsewhere, say where underresources.d1, keyed by the D1 binding, with paths relative to the repository's root:migrationsDirfor another folder of migrations;migrationsGlobfor a glob such asprisma/migrations/*/migration.sql, which works as wrangler'smigrations_dir(the folder before the first*) plusmigrations_pattern, so each file is recorded as wrangler records it (20240101_init/migration.sql) and they run in wrangler's order;schemafor SQL files that run on every install and update after the migrations (the packer accepts them only when every CREATE TABLE, INDEX, TRIGGER and VIEW saysIF NOT EXISTS, nothing is dropped or altered, and rows are only added when missing, withINSERT OR IGNOREorON CONFLICT DO NOTHING;UPDATE,DELETEand other inserts are refused); andpostDeployMigrationsDirfor migrations that must wait until the new version serves all traffic. Migrations and post-deploy migrations are recorded ind1_migrationsby file name, so their names must differ. A rollback reverts neither. For example"resources": { "d1": { "DB": { "schema": ["src/db/schema.sql"] } } }. A schema file creates what is missing and nothing more:CREATE TABLE IF NOT EXISTSnever adds a column to a table that already exists, so when upstream changes a table in its schema file, installs made before the change keep the old table. Changing an existing table needs a migration. -
A full schema file with migrations for older databases. Some apps keep their whole current schema in one file (plain
CREATE TABLE, default rows) and their migrations only bring databases made by older versions up to date, so the migrations fail on an empty database. Name that file as the binding'sbaseline:"resources": { "d1": { "DB": { "baseline": "db/schema.sql" } } }. On install, the baseline runs once on the new database, before the migrations, in one D1 query that also records every migration and post-deploy migration of that version ind1_migrationsas applied, so none of them runs there. The baseline runs only on an empty database (no tables of the app's, nothing ind1_migrations), so updates never run it on a database the install already has; they apply only the migrations added after the installed version, as for any app. A database an update creates for a new binding gets its baseline, since it starts empty. The baseline need not be safe to run twice, but it may notATTACH,DETACHorDROP DATABASE, set a PRAGMA (PRAGMA defer_foreign_keysis allowed), open or end a transaction, or named1_migrationsor asqlite_or_cf_table (even in quotes), or end inside an unclosed comment or string, and it must create at least one table that is not temporary. A binding has a baseline orschemafiles, not both. This works only while upstream keeps the baseline in step with its migrations: an install gets the baseline as it is at the pinned commit and never the migrations of that commit, so a column a migration adds must also be in the baseline. Check that when you submit the entry and when you move its pin. -
secretsandvars. List every secret and setting the app reads fromenvthat is not in its wrangler config. Use"generate": "password"for passwords and signing keys the user does not need to choose. Every var needs a value, typed in the form or from itsdefault, before the install runs, unless it sets"optional": true. List a var from the wrangler config too when admins should be able to change it; without adefault, the form starts with the wrangler config's value. The secrets the wrangler config lists insecrets.requiredbelong here too; the pack names any it finds missing. -
A secret the wrangler config sets as a var. Some apps ship a placeholder password in
vars. Declare the name as a secret: the packer then leaves the var out of the artifact and logsvar <NAME> is provided as a secret, since a Worker cannot have a var and a secret of one name. Never declare one name both as a secret and as a var inappflare.jsonc; the pack refuses that. -
Derived secrets. When the app wants a hash of a password rather than the password, as Counterscale's
CF_PASSWORD_HASHis a bcrypt hash, list the password as a secret and the hash as a second one with"derive": { "from": "CF_PASSWORD", "method": "bcrypt" }. The install form asks only for the password; the manager computes the hash (bcrypt, cost 10) and sets both, and again whenever the password gets a new value. The source must be an ordinary secret of the same manifest, neither optional nor derived, and a derived secret cannot begenerateoroptional. Self-deploying entries cannot derive secrets. -
Web Push (VAPID) keys. For an app that sends push notifications, give the private key secret
"generate": "vapid-private-key": the install form fills in a new P-256 private key, as the unpadded base64url of its 32 raw bytes (the formatweb-pushand similar libraries take), and the manager refuses a value that is not one. Declare the public key as a var with"derive": { "from": "VAPID_PRIVATE_KEY", "method": "vapid-public-key" }: the manager computes it (the unpadded base64url of the 65-byte uncompressed point) at install and whenever the private key gets a new value, and the install and settings forms show it read-only. A derived var has nodefault,options,"type": "select"or"optional": true, and its source is avapid-private-keysecret of the same manifest that is not optional. The samederiveworks on a secret, for an app that reads the public key as one. Self-deploying entries cannot derive vars. -
Raw 256-bit keys. For an app that reads an encryption key as base64 of 32 bytes (it runs
atoband expects 32 bytes back), give the secret"generate": "base64-key-32": the install form fills in 32 random bytes as padded base64 (44 characters), and the manager refuses a value that does not decode to 32 bytes. -
Multi-line secrets. For a value of several lines, such as a PEM private key (a GitHub App's
GITHUB_APP_PRIVATE_KEY), add"multiline": true. The install, update and settings forms ask for it in a monospace text area instead of a one-line password field, which would drop the line breaks. The Worker gets the value as pasted: Windows line endings become\nand spaces or tabs at the end of the last line are dropped; every other character, line breaks included, is kept. A text area cannot hide its text, so the value shows while the admin enters it and cannot be read back once saved. A multi-line secret cannot begenerateorderive. -
A first admin. An app that has no sign-up page and expects its first admin account in the database can have Appflare add it at install. See Seeding a first admin.
-
Optional secrets. Add
"optional": trueto a secret the app works without, such as an SMTP password for a feature that stays off until it is set. The install form shows it among the optional settings and leaves it unset while its field is empty, updates never ask for it, and admins can set or remove it later in the app's settings. Self-deploying entries cannot have optional secrets: their installer runs with every secret the manifest lists. -
Choices. A var that takes one of a few fixed values can say so with
"type": "select"and"options", a list of{ "value", "label" }in the order the form shows them (2 to 20, with distinct values). The form shows cards for up to four options and a dropdown for more.default, when given, must be one of the values. For a var the app reads as JSON, each value is JSON text, such as"true".HOME_PAGEin the example above is one. -
Where to get a value.
helpis shown as plain text, so a link written in it is not clickable. To point at the page where the admin gets the value, such as the page that creates an API key, give the secret or var alink:"link": { "label": "Get a key", "url": "https://openrouter.ai/settings/keys" }. The install, update and settings forms show it beside the field and open it in a new tab without sending the manager's address.labelis one short line of at most 40 characters;urlis an https:// URL of at most 500 characters, without a user name or password. A manager from before this field leaves the link out. -
Placeholders. The manager fills these in within
postInstalltext,vars[].default, and the values of the wrangler config's ownvars, and fills vars in again on every update and settings change:{{appUrl}}is the address the app is served at, as anhttps://URL without a trailing slash: its custom domain while workers.dev is turned off for it, else its workers.dev URL.{{appHostname}}is that address withouthttps://, such aslinks.example.com. When a domain takes over from workers.dev, or workers.dev from a domain, Appflare deploys the app's settings again so they follow. Use these for the address people open: a link inpostInstall, a callback URL, or a public URL setting such as{ "name": "PUBLIC_URL", "label": "Public URL", "default": "{{appUrl}}" }.{{workerUrl}}and{{workerHostname}}are always the workers.dev address (https://<worker name>.<account subdomain>.workers.dev), even while a custom domain serves the app. Use them only when the app must name that address. A setting that uses them keeps the workers.dev URL on when a domain goes live, since the setting would otherwise name an address that no longer answers.{{workerName}}is the name the Worker is installed under.{{accountId}}is the id of the account the app is installed in, for apps that query the Cloudflare API about their own account, such as the Analytics Engine SQL API.{{emailDomain}}and{{emailZoneId}}are, for an app withinstall.emailRouting, the name of the domain the admin chose for its email (example.com) and that zone's id, for a sender such asaccounts@{{emailDomain}}or a setting the app reads as JSON, such as{"{{emailDomain}}":"{{emailZoneId}}"}(placeholders in a JSON var's keys are filled in too; see JSON vars below). Both are empty while Appflare has no domain on record for the app's email. Moving the app's email to another domain deploys the settings again with the new one. An entry withoutinstall.emailRoutingcannot use them, and one that does lists"email-placeholders"inrequires.{{wildcardHostname}}is the hostname of the app's wildcard domain (no scheme, such astunnels.example.com) for an entry withinstall.wildcardHostname, and is empty until the admin assigns one; assigning or removing it deploys the settings again, so the var follows the domain.{{accessTeamDomain}},{{accessTeamName}},{{accessAud}}and{{accessCertsUrl}}are for an app that checks Cloudflare Access's sign-in itself (seeaccessbelow): the account's Zero Trust team domain (<team>.cloudflareaccess.com, no scheme), the team name alone (<team>, for an app that buildshttps://<team>.cloudflareaccess.comitself), the audience (AUD) tag of the app's Access application, andhttps://<team>.cloudflareaccess.com/cdn-cgi/access/certs, where the keys that sign Access's JWTs are published. They work only in a var's value, never inpostInstallor a secret. All four are empty while the app is not protected, so an app that checks them turns everyone away rather than trusting a request without a sign-in. Turning protection on or off deploys the settings again with the new values.{{stage}}works only in the Worker names a self-deploying entry lists ininstall.selfDeploying.workerNames.
Placeholder names are case-sensitive:
{{appURL}}is refused, as is a placeholder in a field that does not take it. An app of several Workers can also name each of its Workers. -
JSON vars. A wrangler config var whose value is not a string (an array, object, number, or boolean) reaches the Worker as that JSON value, as with
wrangler deploy. Whenvarslists such a var, itsdefaultmust be JSON text, such as"[\"inbox@example.com\"]", and the install form asks for JSON. Placeholders are filled in throughout the value, object keys included. Older versions of Appflare fill in values only, so an entry that writes any placeholder in an object key ({"{{appUrl}}":true}as much as{"{{emailDomain}}":"..."}), in a JSON var or in thepropsof a service binding, lists"email-placeholders"inrequires. -
requires. List account features the app needs beyond Workers, such asr2orworkers-ai.accessmeans the app goes with Cloudflare Access: the account needs a Zero Trust organization only while the app is protected, which with"mode": "required"is always; seeaccessbelow.Some values name something Appflare must know how to do rather than something the account must have, and the catalog checks refuse an entry that needs one without it:
secret-keysfor a secret with akeyother than its name,service-propsforpropson a service binding,config-patch-valuesfor a config patch that sets var text or adds a Workers AI binding,email-workerforinstall.emailRouting.worker,email-placeholdersfor{{emailDomain}}or{{emailZoneId}}anywhere in the entry or its wrangler config, or any placeholder in an object key of a JSON var or of service bindingprops, andhyperdrive-cachingforcachingon a Hyperdrive binding. An Appflare that predates the feature does not know the value and leaves the entry out of its catalog, instead of installing the app wrong; a newer one asks the admin nothing about it. A revision may addsecret-keys, together with keys on the secrets it adds (it may not change the key of a released secret), andemail-placeholders, together with a setting or note that uses them. -
tokenPermissions. Only for apps that call the Cloudflare API with a token of their own, never Appflare's. Each entry names one permission group as the Cloudflare dashboard's token form shows it, where it applies, the access the app needs, and why:{ "group": "DNS", "scope": "zone", "access": "edit", "reason": "Updates the DNS record for your home address." }.scopeis"zone"or"account",accessis"read"or"edit", andreasonis one plain sentence (start it with "Optional:" when the app works without the permission).groupmust be a group Appflare can select in the token link. Zone groups: DNS, Zone, Zone Settings, Analytics, Page Rules, Cache Purge, SSL and Certificates, Firewall Services, Load Balancers, Logs, Workers Routes, and Email Routing Rules. Cache Purge has one level, Purge, which"access": "edit"asks for. Account groups: Account Settings, Account Analytics, Billing, Logs, Magic Transit, Workers Scripts, Workers KV Storage, Workers R2 Storage, Workers R2 Data Catalog, Workers R2 SQL, Workers Tail, Workers Containers, D1, Queues, Vectorize, Images, Hyperdrive, Pipelines, Secrets Store, Email Sending, Email Routing Addresses, "Access: Apps and Policies", and "Access: Organizations, Identity Providers, and Groups". Images is for apps that call the Images REST API; a Worker's Images binding needs no token. The secret that takes the token sets"cloudflareToken": true, and the install form shows how to create the token next to it. At most one secret does, and it cannot be generated, derived or seed-only. -
access. Appflare can put any installed app behind Cloudflare Access, so only the manager's users reach it (Appflare's own health checks get in with a service token of the app's own). The install form offers it with the switch off. The optionalaccessblock changes that:"mode": "required"for an app with no sign-in of its own, or one that relies on Access's: it installs only protected, and its protection cannot be turned off. Such an app installs only on accounts that have a Zero Trust organization and a Cloudflare token with the Access permissions (Access: Apps and Policies, Access: Service Tokens, and Access: Organizations, Identity Providers, and Groups); on any other account the install is refused and says which is missing."mode": "recommended"starts the install form's switch on."bypass"lists paths that stay public while the app is protected, such as share links or a webhook:"bypass": ["/s/*", "/.well-known/*", "/api/webhook"]. Each starts with/and may end in/*for everything under it (/s/*does not cover/share); no other wildcard, query or fragment, at most 10. They are public on every address the app answers on: its workers.dev address while it is on, its custom and external domains, and a wildcard domain's base and every name under it. They are never public on version previews. Do not list the health check path: Appflare's checks sign in with the app's token.
An entry with
"mode": "required", or with a var whose default uses one of the Access placeholders, must also list"access"inrequires. That is what keeps it away from a manager too old to protect apps or fill those placeholders in: such a manager does not know the"access"requirement and leaves the entry out of its catalog, instead of installing the app unprotected or with the placeholder as written. Without"mode": "required"the requirement holds only while the app is protected: the app still installs, unprotected, on an account without Zero Trust, and its catalog page says Access is needed only if you protect it.A released app can take up the
accessblock, change it or drop it, and add"access"torequires, with a revision instead of a new pin.Not for the self-deploying tier. Appflare's per-app service tokens are account service tokens, so an Access rule an admin adds in the Zero Trust dashboard with the Any Access Service Token selector lets them in too. An app that verifies the
Cf-Access-Jwt-Assertionheader itself reads the Access placeholders above from its vars. -
authors. Who wrote the app upstream, shown on the catalog card and the app page: one or more{ "name", "url"?, "github"?, "x"? }, with handles written without@. Optional; without it the catalog lists the owner ofrepo. Changing onlyauthorsneeds no new release: the catalog index reads it from the manifest. -
maintainers. At least one GitHub username: the people who package the app for the catalog, shown as Packaged by on the app page. See CODEOWNERS. -
bump. Optional. Version bumps of an artifact tier app merge themselves once their checks pass, the install check included."bump": { "autoMerge": false }has a maintainer merge each one instead, for an upstream whose releases often break installs or need a migration guide. See Version bumps. -
revision. Optional; leave it out for a new app. Raise it to publish a change to the form or copy of a version that is already released. See After merge.
Every field is described in the manifest reference.
Add an optional apps/<slug>/README.md for notes.
Static sites without Worker code
A wrangler config with assets and no main is a Worker that only serves its static
assets, and Appflare installs it the way wrangler deploy uploads one: the artifact
carries the assets and no Worker modules, and the manager uploads the Worker with its
assets and compatibility settings alone. A build that writes the asset directory runs
as usual, from the config's build.command or install.buildCommand; a repository
without a package.json sets "installDirs": [].
Such a Worker has no code to use anything else, so the pack fails when it has
bindings (vars included), catalog secrets or vars, Durable Objects, cron
triggers, queue consumers, an assets.binding, or assets.run_worker_first.
Observability, placement, limits and cache settings are left out, as wrangler
leaves them out, and the pack log says so. The health check requests
install.health.path (default /) as for any app, so the site should answer there. When the asset
directory is the repository root, it needs an .assetsignore that leaves out
everything else that is not part of the site (the wrangler config, the sources), as
wrangler deploy would upload it too; the packer always leaves out .git,
.wrangler and node_modules directories, whatever .assetsignore says.
_redirects and _headers at the root of the asset directory work as they do with
wrangler deploy, for a static site and for a Worker with code alike: the packer
records their rules with the artifact instead of serving them as files, and the manager
sends them with every upload. A sandbox tier entry that is a static site, sets
"installDirs": [], or has a multiline secret needs an up-to-date sandbox Worker,
as does one with buildEnv, "devDependencies": false, Vectorize metadataIndexes
or resources.r2;
an older one is refused with a message to update the sandbox first.
Seeding a first admin
Some apps store their users in D1 and only let an existing admin create others,
so upstream tells you to insert the first admin by hand, or ships a seed file with
a default password. A catalog entry can instead have Appflare insert that row once,
at install, from values the admin enters in the install form. Declare it under
resources.d1[binding].seed:
"secrets": [
{ "name": "ADMIN_PASSWORD", "label": "Admin password", "generate": "password", "seedOnly": true }
],
"vars": [
{ "name": "ADMIN_USERNAME", "label": "Admin user name", "seedOnly": true }
],
"resources": {
"d1": {
"DB": {
"schema": ["worker/schema.sql"],
"seed": {
"hashes": {
"admin": {
"from": "ADMIN_PASSWORD",
"method": "pbkdf2-sha256",
"iterations": 100000,
"saltBytes": 16,
"keyBytes": 32,
"encoding": "base64url"
}
},
"statements": [
{
"sql": "INSERT OR IGNORE INTO users (username, password_hash, password_salt, is_admin) VALUES (?, ?, ?, 1)",
"params": [{ "var": "ADMIN_USERNAME" }, { "hash": "admin" }, { "salt": "admin" }]
}
]
}
}
}
}- Statements. Each is exactly one
INSERT OR IGNORE, or oneINSERT ... ON CONFLICT ... DO NOTHING, so a retried step keeps the row the first attempt added.WITH,DO UPDATE, other statement kinds (CREATE,UPDATE,DELETE,PRAGMA,ATTACHand the like), and thed1_migrations,sqlite_and_cf_tables are refused. At most 10 statements per binding. - Params. Values are never written into the SQL. Put an anonymous
?wherever a value goes and list one param per?, in order (at most 20):{ "var": NAME }or{ "secret": NAME }for a var or secret of the manifest,{ "hash": ID }or{ "salt": ID }for a hash ofhashes, or{ "value": "text" }for literal text. D1 binds them. Numbered (?1) and named (:name) parameters are refused, as is a count of?that differs from the params. - Hashes. Hash a password the way the app checks it.
pbkdf2-sha256takes explicititerations(at most 100,000, the most Cloudflare Workers derive),saltBytesandkeyBytes(16 to 64) and anencodingfor both the hash and the fresh random salt:base64url(unpadded),base64orhex.bcryptgives a$2b$hash with its salt inside, atcost10 unless you set 4 to 10; a bcrypt source longer than 72 bytes is refused at install, since bcrypt ignores the rest. Each hash is computed once per install, so a hash and its salt match. Itsfrommust be a secret of the manifest, neither optional nor derived. - Seed-only values.
"seedOnly": trueon a secret or var means it exists for the seed alone: the install form asks for it once and says so, and it is never set on the Worker, stored in the app's settings, or asked for again by updates or settings. Use it for the admin's password, so the plaintext never sits in the app's environment. A generated seed-only password is shown once more, with a copy button, on the install's job page. A seed-only value must be used by a seed, cannot be optional, derived or limited to some Workers, and no derived value may come from it. A var a seed uses must not be optional unless it has a default. Name the user inpostInstall, never the password. - When it runs. Only the install job seeds, after the binding's migrations,
schema files and post-deploy migrations. With
"beforeSchema": trueit runs before the schema files instead, so the seeded row wins over a default row a schema file adds withINSERT OR IGNORE(claim the default admin's user name, and the upstream default password never lands). Updates never run a seed, even a changed one, and never ask for seed-only values. A rollback restores the Worker only. - What it cannot protect. If an upstream schema file inserts a default admin and
the seeded row is later deleted, the next update's schema file adds the default
again. Say so in the app's
README.md, and prefer an upstream change that drops the default row.
Seeds are not allowed on self-deploying entries.
Apps of several Workers
Some apps are more than one Worker, such as a web front end and an API, or an app
that serves uploaded files from an origin of their own. List every Worker in
install.workers, and the manager installs, updates, and uninstalls them together
as one app:
"install": {
"packageManager": "pnpm",
"wranglerConfig": "apps/web/wrangler.jsonc",
"workerName": "notes",
"buildCommand": "pnpm run build",
"workers": [
{ "name": "web", "wranglerConfig": "apps/web/wrangler.jsonc", "primary": true },
{ "name": "api", "wranglerConfig": "apps/api/wrangler.jsonc" }
]
},
"secrets": [
{ "name": "SESSION_SECRET", "label": "Session secret", "generate": "password", "workers": ["web"] },
{ "name": "API_KEY", "label": "API key", "generate": "password" }
],
"vars": [{ "name": "API_URL", "label": "API URL", "default": "{{appUrl:api}}" }]-
Two to 24 Workers, on the artifact tier only. Each has a
namewithin the entry: up to 24 lowercase letters, digits, and hyphens, not starting or ending with a hyphen. Each wrangler config must have anameof its own. -
The Worker count does not decide the plan. Set
"plan"for what the app needs, as for an app of one Worker. The manager installs and updates all of an app's Workers in one job, and each Worker adds requests to it. Workers Free lets a job make 50 at a time, so there a job for more Workers pauses for 5 minutes whenever it needs a fresh allowance and then carries on; the install takes longer, nothing else changes. On Workers Paid a job may make 10,000 requests at a time. Either way a job may run a limited number of steps (1,024 on Workers Free, 10,000 on Workers Paid), and each Worker's upload runs in a request of its own, so 24 Workers fit; the manager totals the job's steps before it starts and refuses a job that would not fit. Older versions of Appflare installed at most three Workers per app on Workers Free: the catalog's index marks a"free"entry of four or more so that they leave it out of their catalog and ask to be updated, instead of offering an app they cannot install. -
Every Worker counts toward the account's limit: 100 Workers on Workers Free, 500 on Workers Paid. The install counts the account's Workers first and stops before creating anything when the app's Workers would not fit.
-
Exactly one is
primary. It is the app: it runs under the install's Worker name and serves the app's address, its custom domains, and the health check (install.health.pathis a path on it). ItswranglerConfigmust equalinstall.wranglerConfig, so tools that build one Worker build the primary. Every other Worker runs as<install Worker name>-<name>on its own workers.dev address:notes-apiin the example. -
Workers only the app calls. Set
"workersDev": falseon a Worker that only the entry's other Workers reach, through a service binding or a Durable Object binding, and that must not answer from the internet: for example one that trusts identity headers the primary Worker sets. The manager keeps its workers.dev URL and its version previews off on every install, update, rollback and settings change, skips its preview check during updates, and the app's page lists it as not reachable from the internet. Leave it out for every Worker that people or other services call, such as a file origin, an inbox, or a webhook endpoint. The primary Worker cannot set it: it is the app's address and health check until the admin adds a custom domain, which turns its workers.dev URL off anyway.{{appUrl:<name>}},{{workerUrl:<name>}}and their hostname forms are refused for such a Worker, since it has no address. -
Builds.
install.buildCommandruns once, first. Then each Worker's ownbuildCommand, if it has one, runs in the order of the list. Both follow the rules forinstall.buildCommandabove. -
Secrets and vars. A secret's or var's
workerslists the Workers that get it. Without it, a secret goes to every Worker, and a var goes to the Workers whose wrangler config declares it, or to every Worker when none does. -
One secret name, a value per Worker. When several Workers read a secret of the same name that must differ between them (each its own OAuth client, say), declare one secret per Worker with that
name, akeyof its own, andworkers, and list"secret-keys"inrequires:"requires": ["secret-keys"], "secrets": [ { "name": "CLIENT_ID", "key": "GITHUB_CLIENT_ID", "label": "GitHub client ID", "workers": ["github"] }, { "name": "CLIENT_ID", "key": "GOOGLE_CLIENT_ID", "label": "Google client ID", "workers": ["google"] } ]The key is what Appflare knows the secret by: one field each in the install form and the app's settings, one record each, and the name
derive.from, seed params and a sink'stokenSecretuse. Each Worker still readsCLIENT_ID. Keys are letters, digits and underscores, unique among the entry's secrets; a secret without one is known by its name. Secrets of one name must each listworkers, and no Worker may get two of them. A new version may give a released secret a key (or a new one): when every Worker that gets it already has a value under that name, updates keep the value under the new key; otherwise they ask for it. -
The Worker that receives email. An app that receives email (
install.emailRouting) has its primary Worker receive it unlessworkernames another of its Workers, by itsnamewithin the entry:"requires": ["email-worker"], "install": { // ... "emailRouting": { "rules": ["inbox"], "catchAll": true, "worker": "api" } }That Worker exports the
emailhandler, and the routing rules and catch-all Appflare sets up deliver to it as installed (notes-apiin the example). Email Routing hands mail to a Worker by name, not over HTTP, so a Worker with"workersDev": falsecan receive mail. A new version may name another Worker: the update points the app's rules and catch-all at it, and a rollback points them back. List"email-worker"inrequires: an older Appflare would send the mail to the primary Worker. -
A var a Worker needs for itself. Set it in that Worker's
configPatch(seeinstall.configPatchabove), with placeholders such as{{appUrl}}. -
Placeholders.
{{appUrl:<name>}},{{appHostname:<name>}},{{workerUrl:<name>}},{{workerHostname:<name>}}and{{workerName:<name>}}give one Worker's address, its workers.dev address and its installed Worker name (the address forms only for a Worker on workers.dev). Without a name,{{appUrl}},{{workerUrl}},{{workerName}}and the rest still mean the app, that is, the primary Worker. An entry of one Worker cannot use the forms with a name. -
Resources are shared by binding name. Workers that both bind
DBuse one D1 database, so they must bring the same migrations, or only one of them brings any. A queue one Worker sends to and another consumes is one queue. Bindings of one name must be of one type and declared alike. -
Bindings between the Workers. A service binding whose
serviceis another Worker's wranglername, and a Durable Object or Workflow binding whosescript_nameis, are pointed at that Worker as installed. Durable Objects and Workflows may live in any of the Workers. Each Worker is deployed after the Workers it binds to, with the primary as late as possible. -
Workflows run from another Worker. A Workflow belongs to the Worker whose wrangler config binds it without a
script_name(or with its ownname): the manager creates it with that Worker, names it<install Worker name>-<workflow name>like any other, and deletes it on uninstall. Another Worker of the entry runs it with a binding of the samenameandclass_nameand ascript_namenaming the Worker that defines it, as wrangler's cross-script Workflow bindings do; the binding is pointed at that Worker as installed:// wrangler.audit.jsonc (the Worker "audit") "workflows": [{ "name": "site-audit", "binding": "SITE_AUDIT", "class_name": "SiteAudit" }] // wrangler.jsonc (the primary Worker) "workflows": [ { "name": "site-audit", "binding": "SITE_AUDIT", "class_name": "SiteAudit", "script_name": "open-seo-audit" } ] -
Workers built together by the Cloudflare Vite plugin. A Vite app whose
vite.config.tsbuilds other Workers (the plugin'sauxiliaryWorkers) generates one config per Worker,dist/<name>/wrangler.json, and leaves one redirect,.wrangler/deploy/config.json, which points at the entry Worker's config and lists the others. Name each Worker's own config,wrangler.jsoncand, say,wrangler.audit.jsonc, just as for one Worker: the packer reads which config each generated one came from and packs the right one for each Worker. Oneinstall.buildCommandbuilds them all.
The pack fails on a service binding to any Worker outside the entry (the manager refuses to install a Durable Object or Workflow binding to one), on Workers that bind each other in a cycle, on a Workflow binding to another Worker of the entry that defines no Workflow of that name and class, on two Workers that define one Workflow, and on one D1 binding with different migrations in two Workers.
2. Open a pull request
Commit messages follow Conventional Commits, for example feat(apps): add cut.
The pull request runs these checks:
- The manifest is validated against the schema, which refuses a field it does not
know, and
CODEOWNERSmust match the manifests. - The app is packed from the pinned commit exactly as a release would be, and every file's hashes are verified. Packing fails if a Worker is too large for the manager to upload in one request.
- The install check. The packed app is deployed to a dedicated Cloudflare test account with random secrets and default settings. CI then requests its health path for up to 60 seconds, and deletes everything again. A server error, or no answer at all, fails the check. A plain 404 at the end passes.
Pull requests from forks get no CI secrets. The checks build the packer from the
appflare/appflare repository, which is private until launch and needs a secret to
read, so a pull request from a fork fails the verify check until that repository
is public.
CODEOWNERS
CODEOWNERS is generated from each manifest's maintainers: one line
/apps/<slug>/ @user … per app. Maintainers review changes to their app,
including version bumps. Regenerate it with pnpm gen-codeowners
in the catalog checkout and commit the result; CI fails if it is out of date.
After merge
Merging to main publishes the app: CI packs it again, signs it, creates the
release <slug>@<version>, and adds it to index.json. Managers pick it up at their
next catalog refresh, within 30 minutes.
To change a published app's manifest, re-pin source in the same pull request.
A change to a released version without a new pin fails to publish, with two
exceptions:
- a change to
authors,taglineorlicenseNotealone, whichindex.jsonreads from the manifest; - a change to the form or copy only (
name,summary,homepage,license,categories,maintainers,secrets,vars(theirlinkincluded),postInstall,bump,openPath), or to how the app goes with Cloudflare Access (theaccessblock, and adding"access"torequires), together withrevisionraised by one (it starts at 1 when omitted). CI signs and publishes the revised manifest without building anything; managers switch to the new form without an update. For an app that is already protected, a manager checks for a revision every 30 minutes (seven protected apps a run): a path a revisedbypassdrops stops being public in the same run, but a path it adds stays behind the sign-in until an admin of that manager makes it public, and"mode": "required"on an app installed unprotected asks its admin to turn protection on rather than doing it. Anything else, such asinstall,plan,tokenPermissionsor another change torequires, changes what gets built or provisioned and needs a new pin. Once a revision is published, any further change to the manifest,authors,taglineandlicenseNoteincluded, needs the next revision.