Deploying: example recipes
Complete codebuilder.yaml recipes for shipping common kinds of apps, each with its one-time setup. Copy one, change the names, and check it with bakery recipe validate.
Artifacts leave BuildBakery in two ways:
- Deliveries, set up once per project (Settings → Delivery or
bakery deliveries add): your server (SFTP), S3/R2 buckets, Google Play, Firebase App Distribution, GitHub Releases and webhooks. They run as each job finishes, filtered by workflow and branch. Options:bakery docs deliveries. - Recipe steps:
publish:for TestFlight and notarization, and your own commands with secret ingredients (bakery env set NAME --secret) for hosts like Cloudflare, Vercel or your servers.
A successful job with an active delivery publishes. Keep test builds from shipping with --workflows, branch filters on deliveries, or manual: true, and check with bakery deliver BATCH --dry-run.
- Web app to your own server (SFTP)
- Web app to Cloudflare Pages (or Vercel, Netlify)
- iOS app to TestFlight
- Android app to Google Play
- Test builds to testers (Firebase App Distribution)
- Flutter app to TestFlight and Google Play
- React Native / Expo app to TestFlight and Google Play
- macOS app outside the App Store (notarized, on GitHub Releases)
- Electron desktop app for macOS, Windows and Linux
- Backend or Docker app to a server over SSH
Web app to your own server (SFTP)
Build a static site or SPA and publish it to a server you control, switching to the new release only when it is complete.
workflows:
web:
platform: web
tools: { node: 22 }
steps:
- npm ci
- npm test -- --ci
- npm run build
- name: Package
script: cd dist && zip -qr "$CB_ARTIFACTS_DIR/site.zip" .One-time setup
# Once: add the server (SSH key login; its host key is pinned)
bakery servers add --host web.example.com --user deploy --key ~/.ssh/deploy_key
# Once: deliver main-branch builds, unpack them and switch the "current" link
bakery deliveries add sftp --to web.example.com --folder 'releases/{batch}' \
--files 'site.zip' --workflows web --branches main \
--command 'unzip -q site.zip -d site && ln -sfn "$BB_DIR/site" ~/www/current'- Point the web server (nginx, Caddy…) at ~/www/current. Each release lands in its own folder, so a failed upload never breaks the live site.
- Builds from other branches still bake (and stay downloadable) but are not delivered.
Web app to Cloudflare Pages (or Vercel, Netlify)
Deploy with the host’s own CLI from a recipe step, using a secret token from ingredients.
workflows:
web:
platform: web
tools: { node: 22 }
steps:
- npm ci
- npm run build
- name: Deploy
script: |
if [ "$CB_BRANCH" = main ]; then
npx --yes wrangler@3 pages deploy dist --project-name my-site --branch main
else
npx --yes wrangler@3 pages deploy dist --project-name my-site --branch "$CB_BRANCH" # preview URL
fiOne-time setup
# Once: the API token (Cloudflare → My Profile → API Tokens, "Cloudflare Pages: Edit") bakery env set CLOUDFLARE_API_TOKEN --secret --stdin # paste the token, then Ctrl-D bakery env set CLOUDFLARE_ACCOUNT_ID 0123456789abcdef0123456789abcdef
- Secret ingredients reach steps as environment variables and are masked in logs.
- Vercel: `npx --yes vercel deploy --prod --token "$VERCEL_TOKEN"`. Netlify: `npx --yes netlify-cli deploy --prod --dir dist` with NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID.
- Prefer `manual: true` on the workflow if deploys should only happen when you ask (`bakery bake --workflows web`).
iOS app to TestFlight
Sign with the account’s App Store Connect key, build an .ipa on a Mac oven and upload it to TestFlight.
workflows:
ios:
platform: ios
xcode: "26"
code_signing: { distribution: app_store, bundle_id: com.example.app }
steps:
- name: Pods
script: pod install
- name: Build IPA
script: |
xcode-project build-ipa --workspace App.xcworkspace --scheme App
artifacts: [build/ios/ipa/*.ipa]
publish:
app_store_connect:
submit_to_testflight: true # also submit for external beta review
what_to_test: New onboarding flowOne-time setup
# Once: connect an App Store Connect API key (Account → Integrations → Apple) # Once, for external testers: fill in TestFlight test information # (review contact on the Apple key; feedback email and demo account on Settings → Signing)
- Build numbers must increase: set CFBundleVersion from $CB_BUILD_NUMBER (e.g. `agvtool new-version -all $((1000 + CB_BUILD_NUMBER))` in a step) above anything uploaded before.
- Without submit_to_testflight the build goes to internal testers only.
- If the upload works but only the beta review submission fails, the job still succeeds with a warning: the build is in TestFlight.
Android app to Google Play
Sign an .aab with the project keystore, check the signature and upload it to a Play track.
workflows:
android:
platform: android
android_signing: { keystore: release }
verify_signing: { keystore: release } # fail if signed with any other key
steps:
- name: Bundle
script: ./gradlew bundleRelease -PversionCode=$((1000 + CB_BUILD_NUMBER))
artifacts:
- app/build/outputs/bundle/release/*.aab
- app/build/outputs/mapping/release/mapping.txtOne-time setup
# Once: the upload keystore (you type its passwords; never commit it) bakery keystore add /full/path/upload.jks --alias upload --name release # Once: a Google service account with release permission (Account → Integrations → Google Play) # Once: send main-branch builds to the internal track bakery deliveries add google-play --to play-uploader --package com.example.app --track internal \ --workflows android --branches main # Later, to roll out to production gradually: # bakery deliveries set 1 --option track=production --rollout 10
- The very first build of a new app must be uploaded by hand in Play Console once; apps never published only accept `--status draft`.
- mapping.txt is attached to the version for readable crash reports.
Test builds to testers (Firebase App Distribution)
Send every build from a branch to a tester group, Android and iOS (ad hoc).
workflows:
android:
platform: android
android_signing: true
steps:
- ./gradlew assembleRelease -PversionCode=$CB_BUILD_NUMBER
artifacts: [app/build/outputs/apk/release/*.apk]
ios:
platform: ios
code_signing: { distribution: ad_hoc, bundle_id: com.example.app }
steps:
- pod install
- xcode-project build-ipa --workspace App.xcworkspace --scheme App
artifacts: [build/ios/ipa/*.ipa]One-time setup
# Once per app (the Firebase App IDs are in Project settings → Your apps) bakery deliveries add firebase --to firebase-uploader --app-id 1:123:android:abc --groups qa \ --workflows android --branches 'develop,release/*' bakery deliveries add firebase --to firebase-uploader --app-id 1:123:ios:def --groups qa \ --workflows ios --branches 'develop,release/*'
- Ad hoc iOS builds only install on devices registered in the Apple developer account.
- Pull request builds also get an install link and QR code in the PR comment (Triggers), without Firebase.
Flutter app to TestFlight and Google Play
One recipe for both stores: Android on a Linux oven, iOS on a Mac oven, in parallel.
workflows:
android:
platform: android
android_signing: { keystore: release }
verify_signing: { keystore: release }
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=$((1000 + 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
- flutter build ipa --build-number=$((1000 + CB_BUILD_NUMBER)) --export-options-plist=$HOME/export_options.plist
artifacts: [build/ios/ipa/*.ipa]
publish:
app_store_connect: { submit_to_testflight: true }One-time setup
bakery keystore add /full/path/upload.jks --alias upload --name release bakery deliveries add google-play --to play-uploader --package com.example.app --track internal --workflows android --branches main
- The code signing step writes ~/export_options.plist for the profile it installed.
- Use the same build number offset on both platforms so versions line up.
React Native / Expo app to TestFlight and Google Play
Generate the native projects with Expo prebuild, then sign and ship each platform.
workflows:
android:
platform: android
tools: { node: 22 }
android_signing: { keystore: release }
steps:
- npm ci
- npx expo prebuild --platform android --no-install
- name: Bundle
working_directory: android
script: |
./gradlew bundleRelease -PversionCode=$((1000 + CB_BUILD_NUMBER)) \
-Pandroid.injected.signing.store.file="$CB_KEYSTORE_PATH" \
-Pandroid.injected.signing.store.password="$CB_KEYSTORE_PASSWORD" \
-Pandroid.injected.signing.key.alias="$CB_KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$CB_KEY_PASSWORD"
artifacts: [android/app/build/outputs/bundle/release/*.aab]
ios:
platform: ios
tools: { node: 22 }
code_signing: { distribution: app_store, bundle_id: com.example.app }
steps:
- npm ci
- npx expo prebuild --platform ios
- name: Re-apply signing to the generated project
script: |
export PATH="$HOME/.local/bin:$PATH"
xcode-project use-profiles --project 'ios/*.xcodeproj'
- xcode-project build-ipa --workspace ios/*.xcworkspace --scheme MyApp
artifacts: [build/ios/ipa/*.ipa]
publish:
app_store_connect: { submit_to_testflight: true }One-time setup
bakery keystore add /full/path/upload.jks --alias upload --name release bakery deliveries add google-play --to play-uploader --package com.example.app --track internal --workflows android --branches main
- Signing runs before your steps, so a project generated later (prebuild) needs `xcode-project use-profiles` again.
- Generated Gradle projects take the keystore through the android.injected.signing properties instead of edits to build.gradle.
- Template: `bakery recipe init --template android-expo`.
macOS app outside the App Store (notarized, on GitHub Releases)
Sign with Developer ID, notarize with Apple, and attach the .dmg to a GitHub release.
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
mkdir -p dist && cp -R "$CB_WORK_DIR/MacApp.xcarchive/Products/Applications/MacApp.app" dist/
- name: Disk image
script: hdiutil create -volname MacApp -srcfolder dist -ov -format UDZO "MacApp-$CB_BUILD_NUMBER.dmg"
artifacts: ["MacApp-*.dmg"]
publish:
notarize: { path: "MacApp-*.dmg" } # notarize and staple before artifacts are collectedOne-time setup
# Once: deliver main-branch builds as draft releases (publish them on GitHub when ready)
bakery deliveries add github-release --tag 'v1.{batch}' --files '*.dmg' --workflows mac --branches main- Notarization needs hardened runtime on and every binary signed with Developer ID; Apple’s log lists anything missing.
- `--draft=false` publishes releases straight away.
Electron desktop app for macOS, Windows and Linux
Build installers per platform with electron-builder and collect them in one GitHub release.
workflows:
mac:
platform: macos
tools: { node: 22 }
code_signing: { distribution: developer_id, bundle_id: com.example.desktop }
steps:
- npm ci
- npx electron-builder --mac dmg --publish never
artifacts: [dist/*.dmg]
publish:
notarize: { path: "dist/*.dmg" }
windows:
platform: windows
tools: { node: 22 }
steps:
- npm ci
- npx electron-builder --win nsis --publish never
artifacts: [dist/*.exe]
linux:
platform: linux
tools: { node: 22 }
steps:
- npm ci
- npx electron-builder --linux AppImage --publish never
artifacts: [dist/*.AppImage]One-time setup
bakery deliveries add github-release --tag 'v{batch}' --files '*.dmg,*.exe,*.AppImage' --branches main- Windows jobs need a Windows oven of your own (there are no hosted Windows ovens); signing the .exe uses your own certificate in a step.
- Each platform finishes and is delivered independently; the release collects all three.
Backend or Docker app to a server over SSH
Test and package on BuildBakery, then ship to your server with a deploy key, only when you ask.
workflows:
deploy:
platform: linux
manual: true # only when named: bakery bake --git --workflows deploy
tools: { node: 22 }
steps:
- npm ci
- npm test
- npm run build
- name: Ship
script: |
key="$(mktemp)"; trap 'rm -f "$key"' EXIT; chmod 600 "$key"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key"
tar czf release.tgz dist package.json package-lock.json
scp -i "$key" -o StrictHostKeyChecking=accept-new release.tgz deploy@app.example.com:releases/
ssh -i "$key" deploy@app.example.com "./deploy.sh releases/release.tgz"One-time setup
# Once: the deploy key, from a file (never in the recipe or chat) bakery env set DEPLOY_SSH_KEY --secret --file /full/path/deploy_key # Deploy the pushed main branch: bakery bake --git --branch main --workflows deploy
- `manual: true` keeps pushes and "all workflows" batches from deploying; only a batch that names the workflow runs it.
- Pin the server’s host key (a known_hosts file in the repo, StrictHostKeyChecking=yes) once you have it.
- BuildBakery deploys itself this way.