How deploy decides what to build
forculus deploy looks at the files in a directory to decide whether it’s a static site, a Worker or a container. It only reads files. It checks the rules below in order, and the first rule that matches decides.
Preview what deploy will do
Section titled “Preview what deploy will do”forculus detect runs the same rules and stops. It doesn’t build or upload anything, and you don’t need to be signed in.
cd my-siteforculus detecta static site, served by Workers (Vite) because package.json depends on vite, builds with `npm run build`, deploys what the build writes install: npm ci build: npm run buildThe first line is the kind, with the framework it recognised in brackets. The because line lists every signal that decided it, in the order the rules checked them. The remaining lines show what the deploy will run. A container’s port: line appears only when forculus.json sets a port. A Worker’s compatibility date: line appears when forculus.json sets one, and a main: line when its entry isn’t the file the because line names. A dry run of forculus deploy shows them as its --main and --compatibility-date set them. kv: and queue: lines appear when forculus.json asks for key-value storage or a queue.
When a deploy would be refused, such as a container with no Dockerfile and no runtime named, a but line says why and forculus detect exits 1. With --json, the same reason is in problem, which is null otherwise.
The install: line says when nothing will be installed, and why. With no lockfile and packages missing, it tells you to install them yourself first:
install: none, there's no lockfile to install from. Run `npm install` firstforculus deploy --dry-run prints the same thing and stops:
forculus deploy acme/api --dry-runa container (Express) because package.json depends on express, runs as a node container start: npm start
That was a dry run, so nothing was deployed.start: is the container’s start command. For a Dockerfile forculus writes, it’s the command that Dockerfile runs. For a project with its own Dockerfile, it reads the Dockerfile's CMD.
For a site or a Worker with nothing to build, the dry run also reads what would go up and checks it as the deploy would. A file named like a credential, such as .env, stops it the same way:
a static site, served by Workers because it has index.html and nothing to buildIt would deploy 14 files (212 KiB) from ./handbook.
That was a dry run, so nothing was deployed.A project with a build isn’t built by a dry run, so what the build writes is checked when you deploy.
Add --json to either command for the same answer as JSON, with kind, driver, framework, reasons, build, worker and container fields, and kv and queue when forculus.json asks for them.
The rules, in order
Section titled “The rules, in order”--kind on the command line takes precedence, then kind in forculus.json, then a worker or container section in it. Without any of those, the first rule below that matches decides.
| The directory has | It’s deployed as | |
|---|---|---|
| 1 | A Dockerfile and a wrangler config | Nothing. The deploy stops and asks which you meant. |
| 2 | A Dockerfile | A container, built from your Dockerfile. |
| 3 | A wrangler config (wrangler.jsonc, wrangler.json or wrangler.toml) |
A Worker, built with wrangler. |
| 4 | A Cloudflare adapter in package.json | A Worker, whatever else the project depends on. |
| 5 | A static site builder in package.json, and no Node server | A static site made of the files the build writes. |
| 6 | A Node server in package.json | A container. |
| 7 | deno.json or deno.jsonc, and no start script in package.json |
A Deno container. |
| 8 | A start script in package.json |
A container. |
| 9 | package.json, and the file a Bun server starts from (below) calls Bun.serve |
A Bun container. A Worker can’t run Bun.serve. |
| 10 | requirements.txt, pyproject.toml or Pipfile |
A Python container. With no Python file to start but an entry file from rule 14 or 15, the rules after this one decide instead. |
| 11 | go.mod |
A Go container. |
| 12 | Cargo.toml |
A Rust container. |
| 13 | index.html and no package.json |
A static site, served as it is. With a Worker’s entry beside it, nothing: the deploy stops and asks which you meant. |
| 14 | An entry file: the module or main that package.json names, when it has a default export, or else index.js, index.mjs, src/index.js, src/index.mjs, dist/index.js, dist/index.mjs, worker.js, worker.mjs, src/worker.js or src/worker.mjs |
A Worker, uploaded as it is, or built with wrangler if it imports a package by name. |
| 15 | A TypeScript entry: the .ts file package.json names as module or main, when it has a default export, or else src/index.ts, index.ts, src/worker.ts or worker.ts |
A Worker, built with wrangler. |
A module named with forculus deploy --main is the entry for rules 13 to 15, wherever it is in the project:
a Worker because app/entry.js is named as its entryThe same goes for worker.main in forculus.json, when the project has no wrangler config. A .js or .mjs file named there is uploaded as it is, even with a TypeScript entry such as src/index.ts beside it. If it imports a package by name, it’s built with wrangler, which bundles the package in. The deploy looks again after the project’s own build runs, so an entry the build writes, such as tsc output in dist/, is bundled too.
A --main that names no file in the directory stops the deploy, unless the project has a build that could write it:
error: nope.js isn't in ./my-app. Name the Worker's entry as a path inside it, like src/index.jsA Worker’s entry beside an index.html is a TypeScript entry from rule 15, or an entry from rule 14 whose code has an export default with a fetch. Deployed as a site, it would be served as a file to everyone who can open the app, so the deploy asks instead:
error: This has index.html and src/worker.ts, so it could be a site or a Worker. As a site, src/worker.ts would be served as a file. Pick one with --kind=worker or --kind=static, or set "kind" in forculus.json.A Python file to start is main.py, app.py, server.py, streamlit_app.py or manage.py, or a Django project’s <project>/wsgi.py. A Worker or a site often has a requirements.txt beside it for a script or a tool, and without one of those files it would be a Python app with nothing to run. So rule 10 leaves it out, and rules 11 to 15 decide as they would without it: a page with an index.html is still a site, or still asks whether you meant the site or the Worker. Detection says what it left out:
a Worker because it has index.js, and requirements.txt is left out, with no Python file to runA container from package.json runs on Bun rather than Node when the project depends on @types/bun or bun-types, has a script that runs bun or bunx, or calls Bun.serve (rule 9). The file rule 9 reads is the module or main package.json names, or else the first of index.ts, src/index.ts, server.ts, src/server.ts, index.js and src/index.js. A typed call like Bun.serve<{ user: string }>(...) counts too. When package.json names no file and the project also has a Worker entry from rule 14 or 15 with a default export and a fetch, a Bun.serve in another file is taken for a server kept for local work. The project stays a Worker. A Bun project with a TypeScript entry that doesn’t call Bun.serve, as bun init writes it, is a Worker by rule 15. Deploy it with --kind container, or set "kind": "container" in forculus.json, to run it on Bun. A bun.lock or bun.lockb alone only means Bun installs the packages: the app still runs on Node.
A static site is built by its build script, or, without one, by the script that runs its builder’s own build, such as the VitePress starter’s docs:build (vitepress build docs). VitePress writes to .vitepress/dist in the directory it builds, so that’s what’s deployed. A site builder with neither stops the deploy, since deploying it unbuilt would publish its sources:
error: This is a VitePress site, but nothing in package.json builds it. Deploying it unbuilt would publish its source code instead of the site. Add a build script like "build": "vitepress build docs", or set build.command and build.output in forculus.json.A container with nothing that says how it starts stops the deploy too, before anything is built or made. See the start command.
Between rules 4 and 5, a SvelteKit project (@sveltejs/kit) with none of @sveltejs/adapter-cloudflare, @sveltejs/adapter-cloudflare-workers, @sveltejs/adapter-node or @sveltejs/adapter-static stops the deploy. sv create gives a project @sveltejs/adapter-auto, which builds only for hosts it recognises, so it would write nothing to serve here:
error: This is SvelteKit with @sveltejs/adapter-auto, which only builds for hosts it recognises, and this isn't one. Add an adapter we can run, then deploy again: as a Worker: npx sv add sveltekit-adapter="adapter:cloudflare+cfTarget:workers" && npm run gen as a container: npx sv add sveltekit-adapter="adapter:node"The first also writes the wrangler.jsonc the build needs, and `npm run gen` writes the types the build checks.A package counts if it’s in dependencies or devDependencies.
| Rule | Packages |
|---|---|
| Cloudflare adapter | @opennextjs/cloudflare, @astrojs/cloudflare, @sveltejs/adapter-cloudflare, @sveltejs/adapter-cloudflare-workers, @react-router/cloudflare, @cloudflare/vite-plugin, nitro-cloudflare-dev, @remix-run/cloudflare, wrangler, hono, @hono/vite-build |
| Static site builder | @docusaurus/core, vitepress, @11ty/eleventy, react-scripts, astro, vite, parcel |
| Node server | express, fastify, koa, @nestjs/core, next, nuxt, @sveltejs/adapter-node, @react-router/node, @hapi/hapi |
The same framework can be deployed as different kinds. Next.js with @opennextjs/cloudflare is a Worker, and Next.js without it is a container. Workers and frameworks has the full table.
When a Python project’s dependency files name fastapi, flask, django, streamlit or gradio, that framework appears in brackets, and it decides the start command.
When nothing matches
Section titled “When nothing matches”The deploy stops and lists what it looked for:
error: We can't tell what this is. We looked for: - a Dockerfile - a wrangler config (wrangler.jsonc, wrangler.json, wrangler.toml) - package.json with a Cloudflare adapter, a static builder or a server - requirements.txt, pyproject.toml or Pipfile - deno.json - go.mod - Cargo.toml - index.html - an entry point (index.js, index.mjs, src/index.js, src/index.mjs, dist/index.js, dist/index.mjs, worker.js, worker.mjs, src/worker.js, src/worker.mjs, src/index.ts, index.ts, src/worker.ts, worker.ts)
Set the kind with --kind, or in forculus.json.If a directory has both a Dockerfile and a wrangler config, the deploy asks which you meant:
error: This has both a Dockerfile and wrangler.json, so it could be either kind. Pick one with --kind=container or --kind=worker.Set the kind yourself
Section titled “Set the kind yourself”Pass --kind for one deploy:
forculus deploy acme/api ./api --kind container--kind takes static, worker or container. To keep the setting with the project, put it in forculus.json instead:
{ "version": 1, "kind": "container" }With --kind, the reason reads you said it's a container. From forculus.json it reads forculus.json says it's a container. forculus import writes the kind it found to forculus.json, so a deploy after an import gives the file as the reason. Either way the first line still names the framework detection found, when it would have found the same kind. forculus.json covers the rest of the file.
What a deploy runs
Section titled “What a deploy runs”Once it knows the kind, forculus deploy runs the project’s own build on your machine:
-
Install, when there’s a build to run and the directory has a package.json but no
node_modules. The install follows your lockfile without changing it:npm ciforpackage-lock.json,pnpm install --frozen-lockfileforpnpm-lock.yaml,yarn install --immutableforyarn.lock, andbun install --frozen-lockfileforbun.lockorbun.lockb. With no lockfile, nothing is installed, and the build still runs. If it then fails because packages seem to be missing, the error says to install first. This happens when package.json names packages that aren’t innode_moduleshere or in a directory above, or when a command the build runs is missing and there’s nonode_modulesat all:error: `npm run build` failed because package.json lists packages that aren't installed, and there's no lockfile to install them from. Run `npm install` first, then deploy again.It names pnpm, yarn or bun instead when package.json’s
packageManagerdoes. -
Build, with
npm run buildwhen package.json has abuildscript, or for a static site the script that runs its builder. A Next.js app with OpenNext runs the adapter’s own build instead (details). -
Check the files to upload, then upload them.
A container builds inside its image, so nothing runs on your machine before docker build unless forculus.json names a build.command.
The build runs with CI=1 and with the app’s environment variables, since a site build bakes some of them in. CLOUDFLARE_API_TOKEN, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_BASE_URL, CF_API_TOKEN, CF_API_KEY and CF_ACCOUNT_ID are removed from its environment, so the build can’t reach your own Cloudflare account.
The build’s output goes to stderr. With --json, stdout carries only the result.
If a step fails, nothing is uploaded or created:
error: `npm run build` failed. Fix it the way you normally would, then deploy again.The same applies after the build. If the upload fails, or a container’s image doesn’t build, an app that --create made for this deploy is deleted.
If a site’s file fails to upload, because the connection drops or times out, or we fail or are busy, the CLI sends it again after a second, then after four more seconds. Then the deploy fails. Deleting an app made for a failed deploy is retried the same way.
--no-build skips the install and the build and deploys what’s already in the directory.
One package of a monorepo
Section titled “One package of a monorepo”A monorepo keeps one lockfile and one node_modules at its root. When the app’s directory has no lockfile of its own, forculus deploy looks up to eight directories above it for the monorepo’s root: a directory with a lockfile, and either a pnpm-workspace.yaml or a package.json with workspaces. It stops searching at the first directory containing .git. The install runs at that root, and forculus detect shows it:
because package.json depends on vite, builds with `npm run build`, deploys what the build writes, installs from the monorepo root at /Users/you/code/shopCredentials in the upload
Section titled “Credentials in the upload”Before sending anything, forculus deploy looks for credentials in a site’s files, a Worker’s modules and a container’s build context. A file named like a credential (.env, .env.production, *.pem, *.key, *.p12, *.pfx, .dev.vars, id_rsa, id_ed25519, credentials.json) stops the deploy. .env.example, .env.sample, .env.template and .env.dist are allowed by name.
A site’s files are also checked for keys in their contents, such as a Stripe or GitHub token. So are those allowed example files, in a site or in a container’s build context.
We check a Worker’s code for keys after it’s uploaded, and refuse the deploy if we find one. With --create, the app made for that deploy is deleted again, so nothing is left behind, unless someone else deployed to it in the meantime. Remove the key and run the same command again.
Put keys in secrets instead, which keep them out of your code.
An app keeps its kind
Section titled “An app keeps its kind”A static site and a Worker both run on Workers. A container runs on Containers. --create makes the app on whichever one the directory needs, and the app can’t move to the other later:
error: acme/api runs on Workers, but . would run as a container. An app can't switch between them. Create a new app, or deploy something of the same kind.A static site can become a Worker, and a Worker a static site, because both run on Workers.
Next steps
Section titled “Next steps”- forculus.json to write down what detection would get wrong.
- Static sites, Workers and frameworks and Containers for each kind.
forculus deployin the CLI reference for every flag.