Skip to content

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:

toml
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:

toml
[[env.production.routes]]
pattern = "your-domain.com"
custom_domain = true

2. Configure .env.production (app behavior only) ​

Edit .env.production with settings that describe how the app behaves:

bash
# OAuth redirect URI for your production domain
VITE_OAUTH_REDIRECT_URI=https://my-app-prod.your-subdomain.workers.dev/oauth/callback

The app ID and backend URL are not here. They are typed once, in primitive/config.json, as a named Primitive environment:

bash
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:

FlagSelectsWhich means
--deploy-env <name>the deploy environmentthe Vite mode (.env.<name>) and the [env.<name>] block in wrangler.toml
--primitive-env <name>the Primitive environmentthe backend/app pair in primitive/config.json
bash
pnpm cf-deploy --deploy-env production --primitive-env prod

Omitting 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:

bash
pnpm cf-deploy --deploy-env production --primitive-env alpha

The 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:

bash
pnpm cf-deploy --deploy-env production --primitive-env prod --check

To pass additional flags to wrangler, use -- followed by the flags:

bash
pnpm cf-deploy --deploy-env production --primitive-env prod -- --dry-run

Adding More Environments ​

The two axes grow separately.

Another deploy environment (another front end — e.g. a test worker):

  1. Add a section to wrangler.toml:
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.

  1. Create a corresponding .env.test for app behavior (no identity keys).

  2. Deploy it against whichever backend you want:

bash
pnpm cf-deploy --deploy-env test --primitive-env prod

Another Primitive environment (another backend/app pair):

bash
primitive env add alpha --api-url https://alpha.primitiveapi.com --app-id app_...
pnpm cf-deploy --deploy-env production --primitive-env alpha

Nothing 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:

dotenv
# .env.production
VITE_EXPECTED_PRIMITIVE_ENV=prod

A 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 ​

  1. Find your team ID at developer.apple.com/account → Membership Details (10 characters, e.g. 2J4V27W63D).

  2. Edit project.yml:

    yaml
    settings:
      base:
        DEVELOPMENT_TEAM: "2J4V27W63D"
        CODE_SIGN_STYLE: Automatic
  3. Regenerate the xcodeproj:

    bash
    bash scripts/regenerate-project.sh

    That script is the one entry point for regeneration: it runs scripts/codegen.sh (models and the typed code generated from your server configuration — xcodegen can only list files that already exist, so a newly emitted one has to be on disk first), then xcodegen generate, and then re-copies the app's Package.resolved into the project container xcodegen just rewrote. ./run-ios.sh, ./archive.sh and 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.sh has 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:

bash
bundle install

fastlane/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.

  1. Go to App Store Connect → Users and Access → Integrations → App Store Connect API.

  2. Create a new key with role App Manager.

  3. Download the .p8 file (you can only download it once) and save it to fastlane/api_key.p8.

  4. Note the Key ID and Issuer ID from the same page.

  5. Copy the shipped template and fill in the three values:

    bash
    cp fastlane/.env.example fastlane/.env
    bash
    # 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:

LaneWhat it does
fastlane ios betaArchive, export, and upload an iOS build to TestFlight
fastlane ios releaseArchive, export, and submit an iOS build to App Store review (sets skip_metadata / skip_screenshots)
fastlane mac betaUpload a macOS build to TestFlight
fastlane mac dmgBuild a notarized DMG for direct distribution
fastlane bump type:patchBump the marketing + build version in project.yml and regenerate the xcodeproj (major / minor / patch)
fastlane statusPrint 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.

  1. Go to App Store Connect → Apps → + → New App.
  2. 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).
  3. Choose Full Access.

6. Ship a TestFlight Build ​

bash
bundle exec fastlane bump type:patch      # bumps version + build number, regenerates xcodeproj
bundle exec fastlane ios beta             # archives, exports, uploads

A 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 ​

bash
bundle exec fastlane bump type:minor      # if you want to ship a real new version
bundle exec fastlane ios release

upload_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.

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30