Deploying to Production
Each platform ships through its native channel: the web template deploys to Cloudflare Workers, and the iOS template ships through TestFlight and the App Store with Fastlane.
Web (Cloudflare Workers)
The web template deploys to Cloudflare Workers. You'll need a Cloudflare account with access to deploy Workers.
1. Configure wrangler.toml
Edit wrangler.toml to set your worker name:
name = "my-app"
[env.production]
name = "my-app-prod"By default, your app will be deployed to a *.workers.dev URL. To use a custom domain, uncomment and edit the routes section:
[[env.production.routes]]
pattern = "your-domain.com"
custom_domain = true2. Configure .env.production (app behavior only)
Edit .env.production with settings that describe how the app behaves:
# OAuth redirect URI for your production domain
VITE_OAUTH_REDIRECT_URI=https://my-app-prod.your-subdomain.workers.dev/oauth/callbackThe app ID and backend URL are not here. They are typed once, in primitive/config.json, as a named Primitive environment:
primitive env list
primitive env add prod --api-url https://primitiveapi.com --app-id app_...A deploy refuses to run if it finds VITE_APP_ID, VITE_API_URL, VITE_WS_URL or VITE_APP_NAME in a .env file or in your shell — two sources of truth for the app ID is exactly what that config file removes. Delete those lines wherever they appear; there is no flag that allows them.
3. Deploy
A deploy names two independent things, and neither is inferred from the other:
| Flag | Selects | Which means |
|---|---|---|
--deploy-env <name> | the deploy environment | the Vite mode (.env.<name>) and the [env.<name>] block in wrangler.toml |
--primitive-env <name> | the Primitive environment | the backend/app pair in primitive/config.json |
pnpm cf-deploy --deploy-env production --primitive-env prodOmitting either is an error. They cross in practice — a production front end against your alpha backend, or dev and prod builds that both talk to primitiveapi.com with different app IDs:
pnpm cf-deploy --deploy-env production --primitive-env alphaThe script prints the resolved pair (deploy environment, Primitive environment, apiUrl, appId, appName, and the config file they came from) before it builds anything. To see that plus the exact build and wrangler commands without running them:
pnpm cf-deploy --deploy-env production --primitive-env prod --checkTo pass additional flags to wrangler, use -- followed by the flags:
pnpm cf-deploy --deploy-env production --primitive-env prod -- --dry-runAdding More Environments
The two axes grow separately.
Another deploy environment (another front end — e.g. a test worker):
- Add a section to
wrangler.toml:
[env.test]
name = "my-app-test"
[env.test.vars]
REFRESH_PROXY_COOKIE_MAX_AGE = "604800"
REFRESH_PROXY_COOKIE_PATH = "/proxy/"The two vars configure the template's session refresh proxy: how long its cookie lives (seconds; 604800 is 7 days) and the path it is scoped to. Copy them as-is from the existing environments.
Create a corresponding
.env.testfor app behavior (no identity keys).Deploy it against whichever backend you want:
pnpm cf-deploy --deploy-env test --primitive-env prodAnother Primitive environment (another backend/app pair):
primitive env add alpha --api-url https://alpha.primitiveapi.com --app-id app_...
pnpm cf-deploy --deploy-env production --primitive-env alphaNothing in wrangler.toml or .env.* changes for a new backend. Name these entries after the backend/app pair they point at — prod, prod-test, alpha — rather than after a build stage, which is the other axis.
Pinning a mode to a Primitive environment
The axes crossing freely is usually what you want. It stops being what you want when a mode's .env keys are only correct against one backend — a per-environment resource ID, a shared database ULID. Then the wrong pairing is silent: the app runs one backend's identity with another backend's configuration.
Say which environment the mode belongs to, in that mode's file:
# .env.production
VITE_EXPECTED_PRIMITIVE_ENV=prodA run that resolves a different Primitive environment fails at startup, naming both halves and where each came from. It covers every entry point that resolves one — pnpm dev, pnpm build, pnpm test, and pnpm cf-deploy, which checks the declaration before it builds or prints a --check plan.
The key is opt-in: without it, the axes stay independent. A value in the base .env applies to every mode, .env.<mode> overrides it, and an empty value there switches the check off for that mode. To run a cross-wired pair on purpose, state it: VITE_EXPECTED_PRIMITIVE_ENV=dev PRIMITIVE_ENV=dev pnpm test --mode alpha.
Pure-env CI builds
A build that supplies both VITE_APP_ID and VITE_API_URL itself resolves no Primitive environment, so there is nothing to check. Setting only one of them does not skip the check — the other half still comes from the resolved environment, which is exactly the cross-wire worth catching.
iOS (TestFlight and the App Store)
Simulator builds run unsigned — you need an Apple Developer account ($99/year) and a team ID only for physical devices, TestFlight, and the App Store.
If this app also has a web client
Deploying the web side is also what makes the emailed sign-in link open the iOS app — do this once the web app is live on its production domain. The full setup (webUrl, the callback allow-list, iosAppId, and the entitlement, in order) is in Authentication.
1. Signing and Team ID
Find your team ID at developer.apple.com/account → Membership Details (10 characters, e.g.
2J4V27W63D).Edit
project.yml:yamlsettings: base: DEVELOPMENT_TEAM: "2J4V27W63D" CODE_SIGN_STYLE: AutomaticRegenerate the xcodeproj:
bashbash scripts/regenerate-project.shThat script is the one entry point for regeneration: it runs
scripts/codegen.sh(models and the typed code generated from your server configuration —xcodegencan only list files that already exist, so a newly emitted one has to be on disk first), thenxcodegen generate, and then re-copies the app'sPackage.resolvedinto the project container xcodegen just rewrote../run-ios.sh,./archive.shand the fastlane lanes all call it, so this step is only needed when you want the regeneration on its own. It requires xcodegen (brew install xcodegen) and fails with that instruction if it is missing.The generated sources are committed, so a regeneration that changes them is a diff to review and commit — including one produced by a release build.
./archive.shhas no codegen policy of its own: it regenerates and builds like every other path.
After that, device installs and archives both work.
With signing set up, ./run-ios.sh --device installs directly on a paired iPhone — see Run It on the Quick Start page.
2. Set up Fastlane
The iOS template ships Fastlane — the project already has a root Gemfile, plus fastlane/Appfile, fastlane/Fastfile, and fastlane/.env.example. That gets you one-command builds to TestFlight and the App Store, plus version bumping. Install the gem:
bundle installfastlane/Appfile is generic — it reads the app identifier and Team ID from project.yml at runtime, so there's nothing to edit there. Set the Team ID as DEVELOPMENT_TEAM in project.yml (step 1 above).
3. App Store Connect API Key
The lanes below authenticate with App Store Connect using an API key.
Go to App Store Connect → Users and Access → Integrations → App Store Connect API.
Create a new key with role App Manager.
Download the
.p8file (you can only download it once) and save it tofastlane/api_key.p8.Note the Key ID and Issuer ID from the same page.
Copy the shipped template and fill in the three values:
bashcp fastlane/.env.example fastlane/.envbash# fastlane/.env ASC_KEY_ID=ABC123XYZ ASC_ISSUER_ID=00000000-0000-0000-0000-000000000000 ASC_KEY_PATH=./fastlane/api_key.p8
Don't commit the key
Add fastlane/api_key.p8 and fastlane/.env to .gitignore. The .p8 is a private key — leaking it lets anyone upload builds as your team.
If you run a lane before these are set, the Fastfile prints the exact setup steps and stops.
4. The shipped lanes
You don't write the Fastfile — the template ships it, parameterized off project.yml so it works for any app. List the lanes with bundle exec fastlane lanes:
| Lane | What it does |
|---|---|
fastlane ios beta | Archive, export, and upload an iOS build to TestFlight |
fastlane ios release | Archive, export, and submit an iOS build to App Store review (sets skip_metadata / skip_screenshots) |
fastlane mac beta | Upload a macOS build to TestFlight |
fastlane mac dmg | Build a notarized DMG for direct distribution |
fastlane bump type:patch | Bump the marketing + build version in project.yml and regenerate the xcodeproj (major / minor / patch) |
fastlane status | Print the app version, bundle ID, Team ID, signing certificates, and whether the API key is configured |
Each build lane reads the Team ID from project.yml (it stops if it's unset — set DEVELOPMENT_TEAM in project.yml) and loads the API key from fastlane/.env. The iOS lanes then do all their signing through that key: they fetch your Apple Distribution certificate and the App Store provisioning profile from App Store Connect, authenticate the archive with the same key, and export with manual signing. You don't need an Apple ID signed into Xcode or a certificate already in your keychain. (fastlane mac beta uses Xcode automatic signing, so it does need an Xcode account.) Every lane also syncs Xcode's package pin from the app's Package.resolved first (see Updating the Primitive Packages), so an upload can't archive against a package revision you've already updated past.
5. Register the App on App Store Connect
You only do this once per app, before the first upload.
- Go to App Store Connect → Apps → + → New App.
- Pick iOS, set name, primary language, bundle ID (the dropdown shows IDs you've registered — if yours is missing, create it at developer.apple.com/account/resources/identifiers), SKU (any unique string).
- Choose Full Access.
6. Ship a TestFlight Build
bundle exec fastlane bump type:patch # bumps version + build number, regenerates xcodeproj
bundle exec fastlane ios beta # archives, exports, uploadsA first upload takes 10–20 minutes between "Fastlane finished" and the build appearing in TestFlight (Apple processes the binary, runs export compliance, etc.). Subsequent uploads are usually 5 minutes.
Internal testers (added in the App Store Connect UI under TestFlight) get builds immediately — no separate review. External testers and groups need a one-time Beta App Review per major version.
7. Submit to the App Store
bundle exec fastlane bump type:minor # if you want to ship a real new version
bundle exec fastlane ios releaseupload_to_app_store uploads the binary and submits it for review. The lane above sets skip_metadata: true and skip_screenshots: true — fill those in manually in App Store Connect (description, screenshots, keywords, age rating, privacy answers) before the build can actually be reviewed. Once metadata is complete and the build is processed, the review queue takes 24–72 hours typically.
CI
Both ./run-ios.sh and bundle exec fastlane ios beta work in GitHub Actions on a macOS runner. Base64-encode api_key.p8 into a secret and decode it before the lane runs.
The API key alone is enough for a machine that keeps its keychain between runs. A fresh runner has no signing identity, so the lane creates a new Apple Distribution certificate every time — and Apple caps how many your team can hold, so you'll hit the limit and have to revoke certificates by hand. For repeatable CI, install your team's existing signing certificate and its private key on the runner (export the identity to a .p12, store it as a secret, import it into a temporary keychain before the lane) instead of minting one per run.