Writing recipes
A recipe tells BuildBakery how to bake your app: one workflow per thing you want built (iOS, Android, a Linux binary…). Each workflow becomes a job on an oven that can run it, and they all bake in parallel.
Where the recipe lives
Put a codebuilder.yaml file at the root of your repository (or of the folder you upload). BuildBakery reads it at the start of every batch, so the recipe is versioned with your code. You can also:
- Override it per project on the Recipes tab (or
bakery recipe push FILE). The project recipe then wins over the file in the repo. - Override it for one batch with "Use a different recipe for this batch" in the Bake dialog, or
bakery bake --recipe FILE.
Check a recipe any time with bakery recipe validate or the Validate button on the Recipes tab.
Your first recipe
workflows:
web:
platform: web
steps:
- npm ci
- npm run build
artifacts:
- dist/**
That's a complete recipe: one workflow called web that installs dependencies, builds, and keeps everything in dist/. Each step is a bash script run from the root of your source.
Workflows
Everything under workflows: is a workflow, keyed by a short name (letters, numbers, -, _, .). You can bake just some of them: bakery bake --workflows ios,android.
| Field | What it does |
|---|---|
| name | Display name (defaults to the key). |
| platform | ios, android, macos, linux, windows, web or any. Decides which ovens can bake it (below). |
| runs_on | Override the oven OS: macos, linux, windows, or a list like [linux, macos]. |
| labels | Tools the oven must have, e.g. [xcode] or [android-sdk]. Ovens advertise what they have preinstalled. |
| timeout_minutes | Stop the job after this long (default 60, max 1440). |
| env | Plain environment variables for every step. Use Ingredients for secrets. |
| steps | What to run, in order (required). |
| artifacts | Files to keep from the build (glob patterns). |
| deliver | deliver: { folder: android } puts this workflow’s artifacts in a subfolder of every SFTP/bucket delivery. |
| image | Linux ovens: run in this Docker image instead of the BuildBakery default. |
| shell | bash (default), pwsh, powershell or cmd, for every step; a step can set its own. powershell and cmd need runs_on: windows. |
| manual | true: only runs when a batch names it (bakery bake --workflows deploy), e.g. a deploy; normal batches and pushes skip it. |
| tools | Tool versions to install before the steps, e.g. node: 20, python: "3.12", dotnet: 9 (any tool mise supports). |
| cache | Extra cache paths, or false to turn caching off. |
| code_signing | iOS/macOS automatic code signing. |
| android_signing | Sign Android builds with a keystore stored on the project. |
| integrations | Give steps App Store Connect / Microsoft Store credentials for publishing. |
| publish | publish: { app_store_connect: true } uploads the IPA to App Store Connect / TestFlight at the end. |
| verify_signing | Signing guard: fail the job unless its APK/AAB/IPA is signed with the expected certificate. |
| oven_pool | own (only your private ovens), hosted (only BuildBakery ovens) or any (default: yours first). |
Platforms and ovens
| platform | Bakes on | Preinstalled |
|---|---|---|
| ios, macos | Mac ovens (Apple silicon) | Xcode, CocoaPods, fastlane, Flutter, Node 22, JDK 17, xcbeautify, SwiftLint, XcodeGen |
| android | Linux ovens | Android SDK 34/35, build-tools, NDK 27, CMake, JDK 17, Flutter, Node 22 |
| linux, web | Linux ovens, including ones on Windows PCs (WSL or Docker Desktop); web also on Macs | gcc/clang tooling, Node 22 + yarn + pnpm, Bun, Python 3, Ruby, Go, Rust, JDK 17 + 21, .NET 8, PHP + Composer, CMake. Any other tool or version: tools: |
| windows | Windows ovens (builds run directly on Windows) | Whatever the PC has: Visual Studio / Build Tools, MSBuild, .NET, Git Bash, PowerShell |
Bring your own machine as an oven from the Ovens page or bakery ovens add: a Linux box, an Apple-silicon Mac, or a Windows PC (it builds Linux jobs in WSL or Docker Desktop, or Windows jobs natively). The oven picks how many jobs it runs at once from its CPUs and RAM (about 2 cores and 6 GB per Android build); Edit on the Ovens page shows the suggestion. If no oven can run a job (say, iOS with no Mac oven), the batch says so and you can keep it queued until one is added, or cancel just that job.
Steps
A step is either a single command, or a named script:
steps:
- npm ci # short form: the name is the command
- name: Build release
script: |
./gradlew assembleRelease \
-PversionCode=$CB_BUILD_NUMBER
- name: Lint (don't fail the batch)
script: npm run lint
ignore_failure: true
- name: Build the iOS part
working_directory: ios
script: pod install
- Scripts run in bash with
set -eo pipefail: the first failing command fails the step, and a failed step stops the job (unlessignore_failure: true). - Every step starts in the source root (or
working_directory), with a fresh shell; files persist between steps, shell variables don't. - YAML tip: quote a step that contains
:(e.g.- 'echo "error: x"') or use the|block form. - On Windows ovens a step (or the whole workflow) can use another shell:
shell: pwsh,powershellorcmd. Containers and Mac VMs always use bash.
Tools and versions
Every oven comes with the common tools (see the table above). Need something else, or a specific version? List it under tools: and the oven installs it before the first step, using mise:
tools: node: 20 # 20, 20.11.1, lts or latest python: "3.11" # quote versions like 3.10 (YAML reads 3.1) java: temurin-21 dotnet: 9 zig: 0.13.0
- No
tools:? The oven still installs what your repo pins:.nvmrc,.node-version,.python-version,.ruby-version,.java-version,.go-version,.tool-versionsormise.toml. - Installs are cached per project, so only the first build downloads them.
- An unknown tool or version fails the job before the first step, with mise's message in the log.
Artifacts
artifacts: - android/app/build/outputs/**/*.aab - build/ios/ipa/*.ipa - build/macos/Build/Products/Release/*.app # folders are zipped
Patterns are relative to the source root; ** matches any number of folders. Anything a step copies into $CB_ARTIFACTS_DIR is kept too. Artifacts are collected even when a batch burns, which helps debugging. Download them from the batch page or with bakery artifacts --download ./out.
Delivering artifacts
Artifacts always stay downloadable from BuildBakery. A project can also send them somewhere as soon as each job finishes (an APK doesn't wait for the rest of the batch): set it up in the project's Settings → Delivery (or bakery deliveries add …). Each delivery picks a destination, and optionally which workflows, branches and files it applies to. Test connection checks access without publishing anything.
| Your server (SFTP) | Copy files into a folder over SSH; optionally run a command there afterwards. |
| Bucket | AWS S3, Cloudflare R2, Backblaze B2, MinIO or any S3-compatible storage. |
| Google Play | Upload the .aab (or .apk) to a track: internal, closed, open or production; draft, full or staged rollout. |
| Firebase App Distribution | Send the build to tester groups and emails, with release notes. |
| GitHub Release | Create (or reuse) a release for a tag at the batch’s commit and attach the files. Drafts by default. |
| Webhook | POST signed JSON with 7-day download links to your own endpoint. |
Accounts for destinations (servers, buckets, Google service accounts, webhooks) are added once on the Account page and can be used by all your projects. Secrets are encrypted and never shown again.
Folders, prefixes, tags and release notes can use placeholders:
| {project} | Project name |
| {batch} | Batch number (12) |
| {workflow} | Workflow key (android, ios…) |
| {platform} | android, ios, macos, linux, windows, web |
| {branch} | Git branch (upload for uploaded batches) |
| {commit} | First 8 characters of the commit |
| {date} | Batch date, 2026-11-03 |
- By default only fresh (successful) jobs are delivered; you can include burnt ones. A job with no file matching the delivery's Files filter is skipped (a web job sends nothing to Google Play).
- Failed deliveries show on the batch page with the reason and Retry, and post to the Slack channels that get “Burnt” notifications. Deliver again (or
bakery deliver 12) re-sends a batch.
One folder per job
A delivery has one folder, but a batch can have several jobs. Give each workflow its own subfolder in the recipe with deliver.folder; it is added to the end of every SFTP folder and bucket prefix the job is delivered to:
# Delivery folder: ~/myproject
workflows:
android:
platform: android
steps: [./gradlew assembleRelease]
artifacts: [app/build/outputs/**/*.apk]
deliver:
folder: android # ~/myproject/android/app-release.apk
ios:
platform: ios
steps: [flutter build ipa]
artifacts: [build/ios/ipa/*.ipa]
deliver:
folder: ios/{branch} # ~/myproject/ios/main/Runner.ipa
web:
platform: web
steps: [npm ci, npm run build]
artifacts: [dist]
deliver:
folder: web # ~/myproject/web/dist.zip
The folder is relative (no ~, . or ..) and can use the placeholders above. Each subfolder gets its own SHA256SUMS, a latest folder gets the same subfolder, and a command after upload runs inside it. Google Play, Firebase and GitHub Releases ignore it; webhooks get it as job.folder. Without deliver, put {workflow} in the delivery's folder to split jobs by workflow key.
Your server (SFTP)
Make a key for BuildBakery, ideally for a user that can only write to the delivery folder, and allow it on the server:
ssh-keygen -t ed25519 -N "" -C buildbakery -f buildbakery_deploy # on the server, as the delivery user: cat buildbakery_deploy.pub >> ~/.ssh/authorized_keys
Add the server on the Account page (or bakery servers add --host … --user … --key buildbakery_deploy). We log in once and pin the server's host key: if it ever changes, deliveries stop with a clear error instead of sending your builds to an impostor.
/srv/releases/{project}/{batch}/{workflow}
app-release.aab
mapping.txt
SHA256SUMS # verify with: sha256sum -c SHA256SUMS
/srv/releases/{project}/latest/{workflow} # with "keep a latest folder"
Files land as NAME.part and are renamed when complete, so scripts watching the folder never pick up half a file. An optional command after upload runs in the folder with BB_DIR, BB_FILES, BB_PROJECT, BB_BATCH, BB_WORKFLOW, BB_PLATFORM, BB_BRANCH, BB_COMMIT and BB_BATCH_URL set; its output shows on the batch page and a non-zero exit marks the delivery failed. Commands need a login with a shell (sftp-only accounts can still receive files).
Buckets (S3, R2, B2…)
Create keys that can write to one bucket: on AWS an IAM user with s3:PutObject, s3:GetObject, s3:DeleteObject and s3:ListBucket; on Cloudflare R2 an API token with Object Read & Write for the bucket. Files go under the delivery's prefix with a SHA256SUMS, and optionally a latest/ copy. If the bucket is public (an R2 custom domain, CloudFront…), add its public URL and batches link to the files.
Google Play
- In Google Cloud console, create a service account and a JSON key, and add it on the Account page (
bakery accounts add google key.json). - In Play Console → Users and permissions, invite the service account's email with “Release apps to testing tracks” (and “Release to production” if you'll use it) for your apps.
- Add a Google Play delivery with the package name and track. The .aab is preferred over an .apk; a
mapping.txtartifact is attached for crash deobfuscation.
Play only accepts Draft releases for an app that has never been published, and the first build of a new app must be uploaded by hand in Play Console. Version codes must increase every upload, e.g. --build-number=$CB_BUILD_NUMBER for Flutter.
Firebase App Distribution
Uses the same Google service account; give it the Firebase App Distribution Admin role in the Firebase project's IAM. Set the Firebase App ID (Project settings → Your apps), tester group aliases and/or emails. Android uploads the .apk (or an .aab if the app is linked to Play); iOS uploads the .ipa (ad hoc or development signed).
GitHub Release
Uses a GitHub account linked under Git accounts (accounts connected with “Connect GitHub” can write; a token needs Contents: write). The release is created for the tag (e.g. v1.{batch}) at the batch's commit, as a draft unless you turn that off, and the files are attached. Delivering again replaces the assets.
Webhooks
Each finished job POSTs JSON to your URL. Download links work without signing in for 7 days. Check the signature with the secret shown when you added the webhook:
POST /your/endpoint
X-BuildBakery-Event: artifacts
X-BuildBakery-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the secret>
{
"event": "artifacts",
"project": { "id": "…", "name": "Shop" },
"batch": { "number": 12, "url": "https://buildbakery.com/#/builds/…", "branch": "main", "commit": "1a2b3c…", "message": "…" },
"job": { "workflow": "android", "platform": "android", "status": "success", "folder": "android" },
"artifacts": [
{ "name": "app-release.aab", "size": 31457280, "sha256": "…", "url": "https://buildbakery.com/api/artifacts/…/download?exp=…&sig=…" }
],
"linksExpireAt": "2026-11-10T12:00:00.000Z"
}
// Node.js: verify the signature
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-buildbakery-signature']))) reject();
Keeping artifacts
By default artifacts are kept forever. In Settings → Storage (or bakery retention --days 30) a project can clear artifacts older than N days and/or keep only the newest N batches. The newest fresh batch always keeps its artifacts, and copies already delivered elsewhere are never touched. Cleared batches also drop their uploaded source, so Bake again only works for batches baked from git.
Ingredients and secrets
Project Ingredients become environment variables in every step. Mark keys, passwords and tokens as secret: they're encrypted at rest, never shown again, and masked as ******** in logs.
Other binary files (a Google services JSON, a certificate) can go in base64 and be decoded in a step. For Android keystores, use Android signing instead.
# once, from your machine
bakery env set GOOGLE_SERVICES_JSON --file app/google-services.json --base64 --secret
# in the recipe
steps:
- name: google-services.json
script: echo "$GOOGLE_SERVICES_JSON" | base64 --decode > app/google-services.json
Built-in variables
| CB_BUILD_NUMBER | Batch number in this project (great for versionCode / CFBundleVersion) |
| CB_BUILD_ID, CB_JOB_ID | Unique ids of the batch and this job |
| CB_WORKFLOW, CB_PLATFORM | This workflow’s key and platform |
| CB_BRANCH, CB_COMMIT | Git branch and commit (when known) |
| CB_PROJECT_NAME, CB_PROJECT_ID | The project |
| CB_SOURCE_DIR | Absolute path of the source root |
| CB_ARTIFACTS_DIR | Anything copied here is kept as an artifact |
| CI | Always true |
Caching
Dependencies are cached automatically per project, keyed by your lockfiles: Gradle, CocoaPods, Swift packages, npm, yarn, pnpm, Flutter pub and the tool versions from tools:. Unchanged lockfile → instant restore; changed lockfile → the closest previous cache is restored and a fresh one saved. Add your own paths, or turn it off:
cache:
paths:
- ~/Library/Developer/Xcode/DerivedData # "~/..." or relative to the source
key_files: [ios/Podfile.lock] # new cache when these change
# cache: false
If a build behaves strangely after a dependency change, use Clear cache in project settings or bakery cache clear.
Android signing
Upload your keystore in the project's Settings → Android signing (or bakery keystore add release.jks --alias upload) with its keystore password, key alias and key password. Then turn signing on in the workflow:
workflows:
android:
platform: android
android_signing: true # or { keystore: upload } if the project has several
steps:
- ./gradlew bundleRelease
Every step of that workflow then has:
| CB_KEYSTORE_PATH | Absolute path of the keystore file on the oven |
| CB_KEYSTORE_PASSWORD | Keystore password (masked in logs) |
| CB_KEY_ALIAS | Key alias |
| CB_KEY_PASSWORD | Key password (masked in logs) |
A Set up Android signing step runs first and checks the keystore opens, the alias exists and the key password unlocks the key, so a typo fails in seconds instead of at the end of a Gradle build. Point your release build at the variables:
// app/build.gradle
android {
signingConfigs {
release {
if (System.getenv("CB_KEYSTORE_PATH")) {
storeFile file(System.getenv("CB_KEYSTORE_PATH"))
storePassword System.getenv("CB_KEYSTORE_PASSWORD")
keyAlias System.getenv("CB_KEY_ALIAS")
keyPassword System.getenv("CB_KEY_PASSWORD")
}
}
}
buildTypes {
release { signingConfig signingConfigs.release }
}
}
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
System.getenv("CB_KEYSTORE_PATH")?.let {
storeFile = file(it)
storePassword = System.getenv("CB_KEYSTORE_PASSWORD")
keyAlias = System.getenv("CB_KEY_ALIAS")
keyPassword = System.getenv("CB_KEY_PASSWORD")
}
}
}
buildTypes {
getByName("release") { signingConfig = signingConfigs.getByName("release") }
}
}
Flutter projects usually read android/key.properties; write it in a step instead of changing Gradle:
steps:
- name: key.properties
script: |
cat > android/key.properties <<EOF
storePassword=$CB_KEYSTORE_PASSWORD
keyPassword=$CB_KEY_PASSWORD
keyAlias=$CB_KEY_ALIAS
storeFile=$CB_KEYSTORE_PATH
EOF
- flutter build appbundle
Keep your own backup of the keystore: Google Play needs the same upload key for every update.
iOS and macOS code signing
Connect an App Store Connect API key on Account → Integrations → Apple Developer Portal, then turn signing on per workflow:
code_signing: distribution: app_store # development | ad_hoc | app_store | enterprise (iOS) | developer_id (macOS) bundle_id: com.example.app # key: "Team key" # if you added several keys
A Set up code signing step runs first: it fetches (or creates) the certificate and provisioning profiles for that bundle id, installs them in a fresh keychain, and points your Xcode project at them. Your own steps then just build.
Publishing to the stores
Google Play, Firebase and GitHub Releases are deliveries, set up in the project's settings. App Store Connect / TestFlight uploads run on the Mac oven (Apple's upload tools only run on macOS), as the last step of the workflow:
workflows:
ios:
platform: ios
code_signing: { distribution: app_store, bundle_id: com.acme.shop }
steps:
- flutter build ipa --release --build-number=$CB_BUILD_NUMBER --export-options-plist=$HOME/export_options.plist
artifacts: [build/ios/ipa/*.ipa]
publish:
app_store_connect:
submit_to_testflight: true # beta review, needed for external testers
beta_groups: [QA team] # optional TestFlight groups
# path: build/ios/ipa/*.ipa # default: the first .ipa (.pkg for macos) found
# key: Main key # which Apple key, if you have several
It uses the App Store Connect key from Integrations. Without submit_to_testflight the build is uploaded and appears in TestFlight for internal testers once Apple has processed it. Build numbers must increase with every upload.
Steps can also use the connected accounts directly:
integrations: app_store_connect: true # APP_STORE_CONNECT_ISSUER_ID / _KEY_IDENTIFIER / _PRIVATE_KEY partner_center: true # MS_STORE_ACCESS_TOKEN (temporary) + MS_PARTNER_CENTER_* ids
The Microsoft client secret never reaches the oven; steps get a short-lived token.
Automation and release safety
- Triggers: bake every push or pull request (GitHub, GitLab, Bitbucket), report commit statuses, cancel superseded batches, or bake on a schedule. Project → Triggers, or
bakery triggers. - Why did this burn? AI reads the failed job's log and recipe and suggests a fix (and a corrected recipe). On the batch page, or
bakery why. - Install previews: QR install pages for APKs and IPAs, and pull request comments.
- Insights: build times, success rate, waits and cache hits per workflow (
bakery insights). - Signed provenance: a signed SLSA attestation of what each job built (
bakery provenance). - MCP server at
/api/mcp, on every plan: AI coding agents can bake, read logs and failure analyses, check and re-send deliveries, and list accounts and ingredients (bakery docs automationlists the tools).
Details: bakery docs automation. Some of these depend on your plan (bakery plan).
Ready-made recipes
These build each kind of app. To ship them (your server, Cloudflare, TestFlight, Google Play, testers, GitHub Releases), see the deploying examples.
Android (Gradle)
workflows:
android:
platform: android
android_signing: true # keystore from Settings → Android signing
steps:
- name: Build
script: ./gradlew bundleRelease assembleRelease -PversionCode=$CB_BUILD_NUMBER
artifacts:
- app/build/outputs/**/*.aab
- app/build/outputs/**/*.apk
iOS (Xcode) to TestFlight
workflows:
ios:
platform: ios
code_signing: { distribution: app_store, bundle_id: com.example.app }
integrations: { app_store_connect: true }
steps:
- name: Pods
script: pod install
- name: Build IPA
script: |
xcode-project build-ipa --workspace App.xcworkspace --scheme App
- name: TestFlight
script: app-store-connect publish --path build/ios/ipa/*.ipa --testflight
artifacts: [build/ios/ipa/*.ipa]
Flutter (iOS + Android)
workflows:
android:
platform: android
android_signing: true
steps:
- flutter pub get
- name: key.properties
script: |
printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=%s\n' \
"$CB_KEYSTORE_PASSWORD" "$CB_KEY_PASSWORD" "$CB_KEY_ALIAS" "$CB_KEYSTORE_PATH" > android/key.properties
- flutter build appbundle --build-number=$CB_BUILD_NUMBER
artifacts: [build/app/outputs/bundle/release/*.aab]
ios:
platform: ios
code_signing: { distribution: app_store, bundle_id: com.example.app }
steps:
- flutter pub get
- name: Build IPA
script: |
flutter build ipa --build-number=$CB_BUILD_NUMBER \
--export-options-plist=$HOME/export_options.plist
artifacts: [build/ios/ipa/*.ipa]
The signing step writes ~/export_options.plist for the profile it installed (via xcode-project use-profiles).
React Native
workflows:
android:
platform: android
android_signing: true
steps:
- npm ci
- name: Bundle
working_directory: android
script: ./gradlew bundleRelease
artifacts: [android/app/build/outputs/**/*.aab]
ios:
platform: ios
code_signing: { distribution: ad_hoc, bundle_id: com.example.app }
steps:
- npm ci
- name: Pods
working_directory: ios
script: pod install
- name: Build IPA
script: xcode-project build-ipa --workspace ios/App.xcworkspace --scheme App
artifacts: [build/ios/ipa/*.ipa]
macOS app (Developer ID)
workflows:
mac:
platform: macos
code_signing: { distribution: developer_id, bundle_id: com.example.mac }
steps:
- name: Archive
script: |
xcodebuild -scheme MacApp -configuration Release \
-archivePath "$CB_WORK_DIR/MacApp.xcarchive" archive
cp -R "$CB_WORK_DIR/MacApp.xcarchive/Products/Applications/"*.app "$CB_ARTIFACTS_DIR/"
Linux binaries (Go / Rust)
workflows:
linux:
platform: linux
steps:
- go test ./...
- name: Build
script: |
mkdir -p dist
GOOS=linux GOARCH=amd64 go build -o dist/app-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o dist/app-linux-arm64 .
artifacts: [dist/*]
Web app
workflows:
web:
platform: web
steps:
- npm ci
- npm test -- --ci
- npm run build
- name: Package
script: tar czf "$CB_ARTIFACTS_DIR/site.tar.gz" -C dist .
Troubleshooting
- "Proofing until a … oven is free": no online oven matches the workflow's platform and labels yet. Check the labels, or ask your admin about oven capacity.
- "No … oven is available": nothing registered can ever run this job (for example iOS without a Mac oven). Add one, then Keep waiting (or
bakery keep-waiting 12 --job ios), or cancel just that job (bakery cancel 12 --job ios). - Installing tools failed: a
tools:version (or one pinned in.nvmrcetc.) doesn't exist. The log shows mise's message. - Invalid YAML: the error shows the line and column. The usual cause is an unquoted
:inside a step. - "No build instructions": there's no
codebuilder.yamlin the source root and no project recipe. - Works locally, burns on the oven: run
bakery logs --job NAME; check that secrets are set as Ingredients and tools are in the preinstalled list.