Environment and Configuration
Required environment variables for Bunship web + API runtime.
This page is the source of truth for required env vars in the current repo.
Runtime public config now uses PUBLIC_* names. The legacy NEXT_PUBLIC_*
names are still accepted as fallbacks for compatibility, but only
PUBLIC_AUTH_PROVIDER is a build-time variable used to choose the auth provider
bundle.
Code references:
- web schema:
apps/ship/src/env.ts - auth runtime:
packages/auth/src/server.ts,packages/auth-clerk/src/server.ts - api runtime:
apps/ship-api/src/server.tsandapps/ship-api/src/index.ts - Trigger runtime sync:
apps/ship-api/trigger.config.ts - Docker build:
Dockerfile,.github/workflows/publish-ghcr.yml
Environment Ownership Matrix
Classify env vars by who reads them first. Do not mix build inputs, container runtime env, and Trigger runtime env.
| Scenario | Env ownership | Stores business secrets | Notes |
|---|---|---|---|
| Docker build / GHCR publish | GIT_COMMIT_SHA, VERCEL_ENV, PUBLIC_AUTH_PROVIDER, plus the optional BuildKit secret file infisical_env | No | Only controls image version and auth-provider build variant. Dockerfile sets SKIP_ENV_VALIDATION=1, so the default build does not need business runtime env. |
| Infisical control vars for GHCR Docker build | INFISICAL_IDENTITY_ID, INFISICAL_PROJECT_SLUG, INFISICAL_ENV_SLUG, INFISICAL_DOMAIN, INFISICAL_SECRET_PATH, INFISICAL_RECURSIVE, INFISICAL_INCLUDE_IMPORTS | No | Only used by publish-ghcr.yml to locate the Infisical project/environment/folder through OIDC, write .infisical.docker.env, and pass it to Docker as a BuildKit secret. |
| Trigger deploy CI | TRIGGER_ACCESS_TOKEN, TRIGGER_PROJECT_ID | TRIGGER_ACCESS_TOKEN is secret | Only used by deploy-trigger.yml to deploy tasks. It is not synced as a Trigger task runtime variable. |
| Web/API container runtime | SITE_URL, DATABASE_URL, auth, email, payment, storage, OAuth, public runtime config, etc. | Yes | Injected by the deployment platform when the container starts. OAuth client IDs/secrets belong only here. |
| Web/API Trigger dispatch | TRIGGER_SECRET_KEY, optional TRIGGER_PROJECT_ID | Yes | Lets Web/API select the Trigger adapter and enqueue runs on Trigger.dev. Not a Docker build variable. |
| Trigger cloud task runtime | DATABASE_URL, S3_*, PUBLIC_S3_URL_BASE, provider API keys, AI tuning vars | Yes | Tasks run on Trigger.dev cloud and need their own env sync/config. OAuth client IDs are not needed. |
| BullMQ runtime | REDIS_URL | Yes | Used by self-hosted persistent queue workers; choose BullMQ or Trigger, not both. |
Minimum Required (Web + API)
Without these variables, startup or core auth/payment/storage flows will break.
| Variable | Required | Used by | Notes |
|---|---|---|---|
PUBLIC_AUTH_PROVIDER | Yes (default better-auth) | web, api | better-auth or clerk |
DATABASE_URL | Yes | web, api, auth | Postgres DSN |
SITE_URL | Yes | web, api, auth | Canonical site origin used for SEO, emails, auth links, and absolute URLs |
TRUSTED_ORIGINS | Recommended | api, auth | Comma-separated extra allowed origins for Better Auth and CORS |
PUBLIC_SERVER_URL | Optional | web | Runtime API origin override for browser requests. Set it only when the public API origin differs from SITE_URL; with VERCEL_ENV the target must expose /api/v1, otherwise it must expose /v1. Legacy fallback: NEXT_PUBLIC_SERVER_URL. |
ADMIN_EMAIL_LIST | Yes | web, auth | Comma-separated admin emails |
EMAIL_FROM | Yes | auth | Sender for verification/reset/OTP emails |
RESEND_API_KEY | Yes | web/auth | Email provider key |
S3_ENDPOINT | Yes | web, api | Storage endpoint |
S3_REGION | Yes | web, api | Storage region |
S3_ACCESS_KEY | Yes | web, api | Storage access key |
S3_SECRET_KEY | Yes | web, api | Storage secret |
S3_BUCKET | Yes | web, api | Bucket name |
PUBLIC_S3_URL_BASE | Yes | web, api | Public object URL base. Legacy fallback: NEXT_PUBLIC_S3_URL_BASE. |
STRIPE_SECRET_KEY | Yes | web, api | Stripe API key |
STRIPE_WEBHOOK_SECRET | Yes | web, api | Stripe webhook signature secret |
CREEM_API_KEY | Conditional | web, api | Creem API key (required if using Creem) |
CREEM_WEBHOOK_SECRET | Conditional | web, api | Creem webhook signature secret |
PAYMENT_PROVIDER_DEFAULT | No (default stripe) | api | Default payment provider when product has no active config |
S_GITHUB_PERSONAL_ACCESS_TOKEN | Yes | web, api | GitHub API access in admin/features |
CLOUDFLARE_ACCOUNT_ID | Yes (in web schema) | web | Required by current env validator |
Upload Provider Selection
The shared S3_* variables above are the baseline storage config. Better Upload server routes can also select a provider dynamically from settings, then fall back to env. This is a server-side configuration mechanism, not an end-user provider switcher UI.
Provider resolution order:
UPLOAD_PROVIDER_OVERRIDES[userId]in thesettingstableUPLOAD_PROVIDER_DEFAULTin thesettingstableBETTER_UPLOAD_PROVIDERcloudflare
| Variable | Required | Used by | Notes |
|---|---|---|---|
BETTER_UPLOAD_PROVIDER | No (default cloudflare) | api | Env fallback provider when no settings override applies. Supported values: cloudflare, aws, backblaze, tigris, digitalocean, minio, wasabi, custom. Invalid values are ignored and fall back to cloudflare. |
BETTER_UPLOAD_<PROVIDER>_BUCKET | No | api | Provider-specific bucket fallback, for example BETTER_UPLOAD_AWS_BUCKET or BETTER_UPLOAD_CUSTOM_BUCKET. |
BETTER_UPLOAD_BUCKET | No | api | Shared bucket fallback before S3_BUCKET. |
Credentials and client fields are resolved from the client object in settings first, then from env aliases such as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY and S3_ACCESS_KEY / S3_SECRET_KEY. Configure path-style or provider-specific client behavior in the settings client object, not as a global env variable.
See S3-Compatible Storage for per-user provider examples and bucket resolution details.
Better Auth only (when PUBLIC_AUTH_PROVIDER=better-auth)
| Variable | Required | Used by | Notes |
|---|---|---|---|
BETTER_AUTH_SECRET | Yes | auth/api | Better Auth signing secret |
BETTER_AUTH_URL | No (defaults to SITE_URL) | auth/api | Override auth/API public base URL when it differs from the site origin |
AUTH_SECRET | Yes | web/auth | Required by web env schema |
OAUTH_GITHUB_CLIENT_ID | Yes (if GitHub OAuth enabled) | web/auth | GitHub OAuth |
OAUTH_GITHUB_CLIENT_SECRET | Yes (if GitHub OAuth enabled) | web/auth | GitHub OAuth |
OAUTH_GOOGLE_CLIENT_ID | Recommended | auth | Google OAuth server client ID |
OAUTH_GOOGLE_CLIENT_SECRET | Recommended | auth | Google OAuth server secret |
PUBLIC_OAUTH_GOOGLE_CLIENT_ID | Yes (if Google OAuth enabled) | web | Google One Tap / Sign-In UI. Legacy fallback: NEXT_PUBLIC_OAUTH_GOOGLE_CLIENT_ID. |
Clerk only (when PUBLIC_AUTH_PROVIDER=clerk)
| Variable | Required | Used by | Notes |
|---|---|---|---|
PUBLIC_CLERK_PUBLISHABLE_KEY | Yes | web | Clerk public key. Legacy fallback: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. |
CLERK_SECRET_KEY | Yes | api/auth | Clerk server key |
CLERK_WEBHOOK_SECRET | Yes | api | Used by /api/v1/webhook/clerk |
API Runtime / Deployment Variables
The mounted API route prefix still depends on the entrypoint:
- Next.js
shiproute surface:/api/v1/* - Standalone
ship-apiservice:/v1/*
When PUBLIC_SERVER_URL is omitted, the web app talks to the same-origin /api/v1 surface under SITE_URL. Set it only when the browser-facing API origin differs from SITE_URL. With VERCEL_ENV present, browser requests use ${PUBLIC_SERVER_URL}/api/v1; without VERCEL_ENV, they use ${PUBLIC_SERVER_URL}/v1 for standalone ship-api or Worker-style API origins.
| Variable | Required | Notes |
|---|---|---|
PORT | No (default 9001) | API listen port |
APP_ENV | Recommended | development / production style env flag |
NODE_ENV | Recommended | Node runtime env |
JWT_SECRET | Recommended | API JWT signing secret (fallback exists but not safe for production) |
JWT_EXPIRATION | No (default 7d) | API JWT TTL |
GIT_COMMIT_SHA | No | Build metadata for logs |
BUILD_TIME | No | Build metadata for logs |
CROSS_SUB_DOMAIN | Optional | Cross-subdomain cookie domain |
Production Address Variables
Use this table when updating Infisical, container runtime env, or host-level env.
| Variable | Runtime action | Required | Notes |
|---|---|---|---|
SITE_URL | Keep/add | Yes | Canonical site origin, e.g. https://app.example.com |
TRUSTED_ORIGINS | Keep/add | Recommended | Comma-separated trusted origins for Better Auth and CORS |
BETTER_AUTH_URL | Keep only if needed | No | Defaults to SITE_URL; set only when auth/API public origin differs |
VERCEL_ENV | Keep/default | No | Docker build arg defaults to production; Cloudflare/Vercel runtime should explicitly stay production |
APP_ENV | Keep | Recommended | Business/runtime environment flag |
PUBLIC_SERVER_URL | Set only if needed | No | Use only when the browser-facing API origin differs from SITE_URL; with VERCEL_ENV the target must expose /api/v1, otherwise /v1; legacy NEXT_PUBLIC_SERVER_URL still works |
NEXT_PUBLIC_SITE_URL | Delete | No | Replaced by SITE_URL |
NEXT_PUBLIC_API_PREFIX | Delete | No | Replaced by shared constants |
CORS_ORIGIN | Delete | No | Replaced by TRUSTED_ORIGINS |
API_PREFIX | Delete | No | Prefix is no longer env-driven |
GitHub Variables for Infisical OIDC (GHCR Docker Build)
Add these values as GitHub Repository Variables, not source code. Entry:
GitHub repo > Settings > Secrets and variables > Actions > Variables > New repository variable.
These variables are for the Load Infisical secrets step in publish-ghcr.yml. They only let GitHub Actions locate the Infisical project/env/folder through OIDC; they are not business runtime secrets. Trigger deploy does not need this Infisical OIDC set.
publish-ghcr.yml already declares permissions.id-token: write, so OIDC auth method not found for identity usually does not mean GitHub Actions cannot issue an OIDC token. It means the Infisical identity referenced by INFISICAL_IDENTITY_ID does not have OIDC Auth configured. A newly created machine identity may default to Universal Auth; for GitHub Actions, remove/replace Universal Auth with OIDC Auth.
| GitHub Variable | Where to get it in Infisical | Notes |
|---|---|---|
INFISICAL_IDENTITY_ID | Infisical > Project > Access Control > Machine Identities, open the identity used by GitHub Actions; or find the same identity under Organization Settings > Access Control > Identities | This is not a secret. Authentication must include OIDC Auth. For GitHub Actions, use https://token.actions.githubusercontent.com for both OIDC Discovery URL and Issuer. This workflow uses GitHub Environments, so use repo:<owner>/<repo>:environment:Production for main and repo:<owner>/<repo>:environment:staging for dev; only use repo:<owner>/<repo>:ref:refs/heads/main for workflows that do not reference a GitHub Environment. |
INFISICAL_PROJECT_SLUG | Infisical > Bunship project > Project Settings > General > Project Slug | Must exactly match the Infisical project slug accessed by the workflow. |
INFISICAL_ENV_SLUG | Infisical > Bunship project > Project Settings > Environments, copy the production environment slug | For example prod / production; this is case-sensitive and must exactly match the Infisical environment slug. |
INFISICAL_DOMAIN | The current Infisical instance URL | Infisical Cloud defaults to https://app.infisical.com, so this can be omitted unless using a self-hosted instance. |
INFISICAL_SECRET_PATH | Infisical project secret folder path | Optional. Defaults to /. If the folder is named env under the root, set /env. This is not the same as INFISICAL_ENV_SLUG. |
INFISICAL_RECURSIVE | GitHub Repository Variables | Optional. Defaults to false. Set true only when the Docker build inputs live in nested folders under INFISICAL_SECRET_PATH. |
Troubleshooting order:
OIDC auth method not found for identity: open theINFISICAL_IDENTITY_IDidentity fromProject > Access Control > Machine Identitiesand confirm Authentication includes OIDC Auth. If it still uses Universal Auth, remove/replace it with OIDC Auth.- Subject mismatch: this workflow uses GitHub Environments, so the production branch subject is
repo:<owner>/<repo>:environment:Production, notrepo:<owner>/<repo>:ref:refs/heads/main. Folder with path '/' ... was not found:INFISICAL_ENV_SLUGorINFISICAL_SECRET_PATHdoes not match Infisical. If the folder isenvunder the root, setINFISICAL_SECRET_PATH=/env.
Workflow entry points:
| File | Purpose |
|---|---|
.github/workflows/publish-ghcr.yml | GHCR Docker image build/push. It runs Load Infisical secrets and passes the result to the Dockerfile as a BuildKit secret file. |
.github/workflows/deploy-trigger.yml | Trigger.dev deploy. It does not use Infisical OIDC; it reads Trigger deploy/sync values from GitHub Secrets/Variables. |
GHCR Build Inputs
The private GHCR image is built by .github/workflows/publish-ghcr.yml using Dockerfile. The workflow builds a Better Auth image under the default tags (latest, branch tag, <branch>-<sha>) and a Clerk image under clerk-latest / clerk-<sha>. Runtime env and runtime public config are injected by the running container, so the same provider-specific image can be reused across environments.
Build args:
| Variable | Required | Notes |
|---|---|---|
GIT_COMMIT_SHA | No | Set by the workflow to the GitHub SHA; defaults to local |
VERCEL_ENV | No | Defaults to production for image builds |
PUBLIC_AUTH_PROVIDER | Provided by workflow matrix | better-auth for default tags, clerk for clerk-* tags |
BuildKit secret file: the workflow writes the Infisical output to .infisical.docker.env and passes it to Docker build as infisical_env. The Dockerfile only reads that file during the build; it is not copied into image layers. Use it only for build-time/public config such as VERCEL_ENV or PUBLIC_*. The Docker build sets SKIP_ENV_VALIDATION=1 and must not require runtime-only values such as SITE_URL, DATABASE_URL, S3_*, auth secrets, Stripe keys, or email keys. Those belong to container runtime env.
OAuth variables are not Docker build inputs. OAUTH_GITHUB_CLIENT_ID / OAUTH_GOOGLE_CLIENT_ID are only Web/API auth runtime config when GitHub/Google login is enabled, and they should be injected at container runtime together with the matching *_CLIENT_SECRET. Trigger/AI runtime does not need these two variables.
Trigger dispatch variables are not Docker build inputs either. TRIGGER_SECRET_KEY belongs in Web/API container runtime only, where it selects the Trigger adapter and lets the app enqueue Trigger.dev runs. Do not put it in GHCR build secrets.
Container runtime public config:
| Variable | Required | Legacy fallback | Notes |
|---|---|---|---|
PUBLIC_S3_URL_BASE | Yes | NEXT_PUBLIC_S3_URL_BASE | Public CDN/object URL base |
PUBLIC_OAUTH_GOOGLE_CLIENT_ID | If Google OAuth enabled | NEXT_PUBLIC_OAUTH_GOOGLE_CLIENT_ID | Public Google client ID for One Tap / Sign-In UI |
PUBLIC_CLERK_PUBLISHABLE_KEY | If Clerk enabled | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk publishable key |
PUBLIC_SERVER_URL | No | NEXT_PUBLIC_SERVER_URL | Optional API origin override when it differs from SITE_URL; with VERCEL_ENV target must expose /api/v1, otherwise /v1 |
PUBLIC_GA_ID | No | NEXT_PUBLIC_GA_ID | Google Analytics |
PUBLIC_UMAMI_DATA_ID | No | NEXT_PUBLIC_UMAMI_DATA_ID | Umami analytics |
Optional Feature Variables
| Variable | Feature |
|---|---|
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | KV/Redis features |
TRIGGER_SECRET_KEY, TRIGGER_PROJECT_ID | Web/API runtime dispatch to Trigger.dev; do not put these in Docker build |
REDIS_URL | BullMQ/CMS queues and AI admission/rate metrics; required when using BullMQ |
CRON_SECRET | Auth for the /api/cron/ai-cleanup cron route |
PUBLIC_GA_ID, PUBLIC_UMAMI_DATA_ID | Analytics; legacy NEXT_PUBLIC_* names still work |
NEXT_PUBLIC_APP_VERSION, VERCEL_GIT_COMMIT_SHA | UI version display |
BETTER_UPLOAD_PROVIDER | Upload provider fallback; see the upload provider section above |
OPENAI_API_KEY, OPENAI_API_BASE | Admin AI command/copilot |
REPLICATE_API_TOKEN | Replicate provider |
KIE_API_KEY, KIE_API_BASE_URL | KIE provider |
FAL_API_KEY | FAL provider |
Local .env Example (Better Auth)
PUBLIC_AUTH_PROVIDER=better-auth
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bunship
BETTER_AUTH_SECRET=replace-with-long-random-secret
AUTH_SECRET=replace-with-long-random-secret
SITE_URL=http://localhost:3001
TRUSTED_ORIGINS=http://localhost:3001,http://localhost:9001
ADMIN_EMAIL_LIST=admin@example.com
EMAIL_FROM=Bunship <noreply@example.com>
RESEND_API_KEY=re_xxx
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
S3_ENDPOINT=https://s3.example.com
S3_REGION=auto
S3_ACCESS_KEY=xxx
S3_SECRET_KEY=xxx
S3_BUCKET=bunship
PUBLIC_S3_URL_BASE=https://cdn.example.com
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
CREEM_API_KEY=creem_xxx
CREEM_WEBHOOK_SECRET=creem_whsec_xxx
PAYMENT_PROVIDER_DEFAULT=stripe
S_GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
CLOUDFLARE_ACCOUNT_ID=xxxLocal .env Example (Clerk)
PUBLIC_AUTH_PROVIDER=clerk
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bunship
SITE_URL=http://localhost:3001
TRUSTED_ORIGINS=http://localhost:3001,http://localhost:9001
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx
CLERK_WEBHOOK_SECRET=whsec_xxx
ADMIN_EMAIL_LIST=admin@example.com
EMAIL_FROM=Bunship <noreply@example.com>
RESEND_API_KEY=re_xxx
S3_ENDPOINT=https://s3.example.com
S3_REGION=auto
S3_ACCESS_KEY=xxx
S3_SECRET_KEY=xxx
S3_BUCKET=bunship
PUBLIC_S3_URL_BASE=https://cdn.example.com
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
S_GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
CLOUDFLARE_ACCOUNT_ID=xxx