Deployment
Copy-paste deployment guide for Bunship on Vercel and Cloudflare (TurboRepo).
Deployment Model
Bunship is a TurboRepo monorepo.
Production is deployed as one site (apps/ship) that serves:
- Marketing pages
- App workspace
- Admin pages
- Docs routes and docs search API
- API routes under
/api/v1/*
No separate docs deployment is required.
1. Required Env Vars (Copy First)
Generate a secret first:
openssl rand -base64 32Use this as a baseline production env set:
# Core auth + app URLs
BETTER_AUTH_SECRET="replace-with-long-random-secret"
AUTH_SECRET="replace-with-long-random-secret"
SITE_URL="https://YOUR_DOMAIN"
TRUSTED_ORIGINS="https://YOUR_DOMAIN"
ADMIN_EMAIL_LIST="admin@YOUR_DOMAIN"
EMAIL_FROM="Bunship <noreply@YOUR_DOMAIN>"
# OAuth
OAUTH_GITHUB_CLIENT_ID="xxx"
OAUTH_GITHUB_CLIENT_SECRET="xxx"
OAUTH_GOOGLE_CLIENT_ID="xxx"
OAUTH_GOOGLE_CLIENT_SECRET="xxx"
PUBLIC_OAUTH_GOOGLE_CLIENT_ID="xxx"
# Database + mail
DATABASE_URL="postgresql://..."
RESEND_API_KEY="re_xxx"
# Payment (Stripe / Creem)
STRIPE_SECRET_KEY="sk_live_xxx"
STRIPE_WEBHOOK_SECRET="whsec_xxx"
CREEM_API_KEY="creem_xxx"
CREEM_WEBHOOK_SECRET="creem_whsec_xxx"
PAYMENT_PROVIDER_DEFAULT="stripe"
# Storage (S3-compatible / R2)
S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
S3_REGION="auto"
S3_ACCESS_KEY="xxx"
S3_SECRET_KEY="xxx"
S3_BUCKET="your-bucket"
PUBLIC_S3_URL_BASE="https://cdn.YOUR_DOMAIN"
# Misc required by current validators
S_GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxx"
CLOUDFLARE_ACCOUNT_ID="xxx"If you use Better Auth, set both AUTH_SECRET and BETTER_AUTH_SECRET (keeping them identical is fine). If you use Clerk, provide PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, and CLERK_WEBHOOK_SECRET instead.
/api/v1 is now a fixed same-origin route prefix. You do not need a separate public API origin variable unless you intentionally expose auth/API on another domain, in which case set BETTER_AUTH_URL explicitly and add the extra origins to TRUSTED_ORIGINS.
PUBLIC_AUTH_PROVIDER is still a build-time variable because it selects the auth provider bundle. Other public values should use PUBLIC_* at runtime; legacy NEXT_PUBLIC_* names still work as fallbacks.
Setup references:
- Better Auth docs: https://www.better-auth.com/docs/introduction
- Clerk docs: https://clerk.com/docs
- GitHub OAuth app: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
- Google OAuth: https://developers.google.com/identity/protocols/oauth2
- Stripe keys + webhook: https://docs.stripe.com/keys, https://docs.stripe.com/webhooks
- Resend API keys: https://resend.com/docs
- Full env list in this repo: /en/docs/operations/env-config
2. Vercel Deployment (TurboRepo)
Because this repo is TurboRepo-based, there are two valid setups.
Option A (Recommended): Turbo Root Mode
Use the repository root as Vercel Root Directory.
| Vercel field | Value |
|---|---|
| Framework Preset | Next.js |
| Root Directory | . (repo root) |
| Install Command | bun install |
| Build Command | bunx turbo run build --filter=@bunship-ai/ship... |
| Output Directory | empty (Next.js default) |
Why this mode: clearer monorepo behavior, better with Turbo dependency graph and cache.
Option B: App Root Mode
If you keep Root Directory = apps/ship, use:
| Vercel field | Value |
|---|---|
| Framework Preset | Next.js |
| Root Directory | apps/ship |
| Include files outside root directory in Build Step | Enabled |
| Install Command | bun install |
| Build Command | bun run build |
| Output Directory | empty (Next.js default) |
In App Root Mode, keep “Include files outside root directory in Build Step” enabled, otherwise workspace packages may not resolve.
After deploy, configure Stripe webhook endpoint:
https://YOUR_DOMAIN/api/v1/webhook/stripe
Vercel references:
- Monorepo guide: https://vercel.com/docs/monorepos
- Build settings: https://vercel.com/docs/builds/configure-a-build
- Turbo filtering: https://turbo.build/repo/docs/crafting-your-repository/running-tasks#using-filters
3. Cloudflare Deployment (OpenNext)
This repo already includes OpenNext Cloudflare config (apps/ship/open-next.config.ts + apps/ship/wrangler.jsonc).
Run from repo root:
bun install
cd apps/ship
bunx wrangler login
bun run deploybun run deploy executes:
opennextjs-cloudflare buildopennextjs-cloudflare deploy
Cloudflare references:
- OpenNext Cloudflare docs: https://opennext.js.org/cloudflare
- Wrangler config: https://developers.cloudflare.com/workers/wrangler/configuration/
- Secrets/variables: https://developers.cloudflare.com/workers/configuration/secrets/
If you deploy on Cloudflare, set VERCEL_ENV=production in runtime variables to match production behavior expected by parts of this template.
4. GHCR Docker Image
This repo includes a GitHub Actions workflow for publishing a private Docker image to GitHub Container Registry:
- Workflow:
.github/workflows/publish-ghcr.yml - Image target:
ghcr.io/<owner>/<repo> - Build file:
Dockerfile - Trigger: pushes to
mainthat touch app/package/build files, or manualworkflow_dispatch
The workflow builds two auth-provider variants. The default image tags use Better Auth. Clerk gets explicit Clerk tags:
| Auth provider | Tags |
|---|---|
| Better Auth | latest on the default branch, branch tag, and <branch>-<sha> |
| Clerk | clerk-latest on the default branch and clerk-<sha> |
The GHCR Docker build does not require business runtime secrets by default. Image builds use build args and can read minimal build inputs from Infisical through a BuildKit secret file; business env is injected by the deployment platform when the container starts.
GitHub Actions variables (GHCR build / Infisical OIDC):
| Variable | Purpose |
|---|---|
INFISICAL_IDENTITY_ID | Optional OIDC identity for Infisical; see the env-config doc for the entry path |
INFISICAL_PROJECT_SLUG | Optional Infisical project slug |
INFISICAL_ENV_SLUG | Optional Infisical environment slug |
INFISICAL_DOMAIN | Optional, defaults to https://app.infisical.com |
INFISICAL_SECRET_PATH | Optional Infisical secret folder path; defaults to /. If the folder is env under the root, set /env |
INFISICAL_RECURSIVE | Optional, defaults to false; set true only when Docker build inputs live in nested folders |
INFISICAL_INCLUDE_IMPORTS | Optional, defaults to true; keep the default when Infisical imports should be included |
VERCEL_ENV | Optional, defaults to production |
The GHCR workflow keeps Load Infisical secrets, writes the output to .infisical.docker.env, and passes it to the Dockerfile as the infisical_env BuildKit secret. That file is read only during the build step and is not copied into image layers. Infisical is only used here to source the minimal Docker build inputs; it does not make business runtime variables such as SITE_URL, DATABASE_URL, S3_*, OAuth, Stripe, or email part of Docker build.
Infisical OIDC checklist: publish-ghcr.yml already has permissions.id-token: write, so OIDC auth method not found for identity usually means the INFISICAL_IDENTITY_ID machine identity still uses Universal Auth. Open it from Infisical > Project > Access Control > Machine Identities, remove Universal Auth, and add OIDC Auth. Set both OIDC Discovery URL and Issuer to https://token.actions.githubusercontent.com. Because this workflow uses GitHub Environments, use repo:<owner>/<repo>:environment:Production for the production subject and repo:<owner>/<repo>:environment:staging for the dev branch.
Docker build args:
| Variable | Purpose |
|---|---|
GIT_COMMIT_SHA | Set by the workflow for image/frontend version metadata |
VERCEL_ENV | Defaults to production |
PUBLIC_AUTH_PROVIDER | Set by the workflow matrix to build Better Auth / Clerk image variants |
Do not put SITE_URL, DATABASE_URL, S3_*, auth secrets, Stripe keys, email keys, or OAuth client IDs/secrets in Docker build. They belong to container runtime env. PUBLIC_S3_URL_BASE, PUBLIC_CLERK_PUBLISHABLE_KEY, PUBLIC_OAUTH_GOOGLE_CLIENT_ID, PUBLIC_GA_ID, PUBLIC_UMAMI_DATA_ID, and PUBLIC_SERVER_URL are also runtime public config, so the same GHCR image can be reused across environments. The old NEXT_PUBLIC_* names still work as fallbacks, but do not use them for new deployments. Do not set NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_API_PREFIX, API_PREFIX, or CORS_ORIGIN.
Set PUBLIC_SERVER_URL only when the browser-facing API origin differs from SITE_URL. With VERCEL_ENV present, requests use ${PUBLIC_SERVER_URL}/api/v1; without VERCEL_ENV, requests use ${PUBLIC_SERVER_URL}/v1.
5. One-Click Deployment Checklist
Vercel
- Import Git repo
- Choose Option A or Option B settings above
- Add required env vars
- Deploy
- Add Stripe webhook endpoint
- Run smoke tests below
Cloudflare
- Create/choose Workers project
- Add required env vars/secrets
-
bun install -
cd apps/ship && bun run deploy - Bind custom domain
- Run smoke tests below
GHCR
- Configure Infisical OIDC variables if minimal Docker build inputs come from Infisical, or keep the workflow default build args
- Confirm the Infisical machine identity Authentication method is OIDC Auth, not the default Universal Auth
- Configure the OIDC Subject for the GitHub Environment:
repo:<owner>/<repo>:environment:Productionformain,repo:<owner>/<repo>:environment:stagingfordev - Confirm image builds do not depend on business runtime secrets
- Confirm
SITE_URLis available only at container runtime - Set runtime public env such as
PUBLIC_S3_URL_BASE,PUBLIC_CLERK_PUBLISHABLE_KEY, andPUBLIC_OAUTH_GOOGLE_CLIENT_IDon the container - Set
PUBLIC_SERVER_URLonly at runtime if the public API origin differs fromSITE_URL, and confirm that origin exposes/api/v1withVERCEL_ENVor/v1withoutVERCEL_ENV - Push to
mainor run the workflow manually - Pull
ghcr.io/<owner>/<repo>:latest, the branch tag, or the branch-scoped sha tag for Better Auth; useclerk-latestorclerk-<sha>for Clerk - Inject runtime env into the container
- Run smoke tests below
6. Post-Deploy Smoke Test
-
/:locale/docs -
/:locale/signin -
/:locale/subscription -
/:locale/admin -
/api/v1/health(if enabled) -
/api/docs/search