YYayaw
Docs

Deployment Environment Setup

How to collect, scope, and verify the environment variables required for deployment.

This page is the deployment operator guide for environment variables. Use it when creating a new Vercel project, restoring a production environment, checking that preview deployments have the same provider wiring as production, or preparing the Docker self-host runtime.

Rules

  • Never commit secrets to .env, .env.production, or any tracked file.
  • Store Vercel production and preview secrets in Vercel project environment variables.
  • Store self-host production secrets in the orchestrator secret store. For the compose baseline, use an untracked .env.self-host file copied from .env.self-host.example.
  • Store local secrets in .env.local or in a pulled .env.local file created by vercel env pull.
  • Keep NEXT_PUBLIC_* values non-secret because they are exposed to the browser bundle.
  • Prefer admin/runtime settings for operational non-secrets when the dashboard supports them. For example, GitHub code-access repository settings are managed from /dashboard/admin/billing-settings; environment values are only fallbacks.

Vercel Workflow

Link the local checkout to the right Vercel project before pulling or pushing environment variables:

vercel link --yes --project <project-name-or-id> --scope <team-or-user>

Add secrets from the Vercel dashboard or CLI. Scope production-only values to Production, preview values to Preview, and local development values to Development:

echo "<secret-value>" | vercel env add VARIABLE_NAME production
echo "<secret-value>" | vercel env add VARIABLE_NAME preview
echo "<secret-value>" | vercel env add VARIABLE_NAME development

After adding or changing variables, redeploy the affected Vercel environment. Existing deployments do not automatically receive updated environment values.

Pull a local copy for development:

vercel env pull .env.local --environment=development --yes

Pull production values only for an explicit audit on a trusted machine:

vercel env pull .env.production.local --environment=production --yes

Check that every key listed in .env.example is present locally:

while IFS='=' read -r key _; do
  [[ -z "$key" || "$key" == \#* ]] && continue
  grep -q "^${key}=" .env.local || echo "Missing in .env.local: $key"
done < .env.example

Self-Hosted Docker Workflow

The portable baseline uses Docker Compose with Postgres, MinIO/S3, the Next.js standalone app server, a Page AI worker, and Caddy:

cp .env.self-host.example .env.self-host
docker compose --env-file .env.self-host -f docker-compose.self-host.yml --profile setup run --rm migrate
docker compose --env-file .env.self-host -f docker-compose.self-host.yml up --build

Docker builds use NEXT_BUILD_WORKERS=1 by default for stable Next.js static generation on small hosts. On larger machines you can raise it for faster builds, for example NEXT_BUILD_WORKERS=2 docker compose --env-file .env.self-host -f docker-compose.self-host.yml up --build.

Self-hosted Docker builds also default NEXT_STATIC_PAGE_GENERATION_TIMEOUT to 180 seconds. This gives slower hosts enough time to prerender large docs pages without tripping Next.js' default 60 second static generation timeout. Lower it on faster CI builders or raise it only if the build logs show legitimate static pages timing out.

Self-hosted Docker builds render Fumadocs pages dynamically instead of pre-generating every docs page during image builds. This keeps small Coolify or Docker hosts from spending most of their deployment budget on documentation pages while still serving docs normally at runtime.

The Compose files tag the app and worker build targets with YAYAW_APP_IMAGE and YAYAW_WORKER_IMAGE. Keep the same worker image tag for the migrate and worker services so Docker or Coolify can reuse the worker target instead of building it twice.

The Yayaw-managed Coolify deployment uses prebuilt images instead of building on the production VM. GitHub Actions builds the app and worker images on GitHub-hosted ubuntu-24.04-arm runners for the Mac mini VM's linux/arm64 platform, pushes the images to GHCR, makes the Coolify server pull them through the private self-hosted runner, updates YAYAW_APP_IMAGE and YAYAW_WORKER_IMAGE, and then asks Coolify to run the compose deployment. This keeps the automatic production release on main while moving the expensive Docker build away from the Mac mini VM and avoiding GitHub Actions artifact storage quota. Coolify must store those image tag variables as build-time and runtime values because Docker Compose interpolates image: entries while preparing the deployment.

The setup profile runs non-interactive drizzle-kit push --force schema sync, the seed script, and bun run dynamic-data:repair-deployed-storage. Re-run it before starting a freshly provisioned self-host database and after schema changes. It is not a versioned Drizzle migration runner; the dynamic-data repair step covers MCP-deployed native ydm_* tables that are intentionally not part of the static Drizzle schema.

Do not commit .env.self-host. For production, move the same values to the target host's secret manager or compose environment. Generate BETTER_AUTH_SECRET before the first Docker build, for example with openssl rand -hex 32; the Dockerfile fails fast when it is empty. The --env-file flag makes the same public values available to Docker build args. BETTER_AUTH_SECRET is also passed to next build so Better Auth route evaluation never falls back to a development secret during image builds. Rebuild the image when NEXT_PUBLIC_* values change because Next.js exposes them in the browser bundle at build time.

Coolify Rolling Migration

docker-compose.coolify.yml remains the legacy deployment unit while the rolling path is validated. It replaces migrate, app, worker, and Caddy together, so it cannot provide a zero-downtime release. Do not point the automatic development or production workflows at the new resources until the manual rolling workflow has passed its rollback checks.

Migrate development first:

  1. Back up Postgres and MinIO. Keep the existing Coolify Compose resource UUID so its postgres_data and minio_data volumes are reused.
  2. Enable Connect to Predefined Network on the existing infrastructure resource. Record the resolved postgres-<resource-uuid> and minio-<resource-uuid> hostnames.
  3. Create a Coolify Docker Image Application from the immutable ghcr.io/<owner>/yayaw-app:<sha> image. Expose container port 3000, do not map a host port, do not set a container name, and connect it to the predefined network. Configure the GHCR registry credentials in Coolify when the package is private; the workflow's temporary pull credentials are not a registry configuration for the Application.
  4. Copy the app runtime environment to that Application. Change DATABASE_URL and S3_ENDPOINT to the resolved infrastructure hostnames. Initially use a temporary development domain.
  5. Keep health checks enabled. The Docker image checks /api/ready, which returns 200 only when Postgres is reachable and the immutable image SHA matches the requested SHA. /api/health remains liveness-only.
  6. Configure a 20-30 second stop grace period in Coolify. Next.js handles SIGTERM and finishes in-flight work during that window.
  7. Move the Page AI worker to a separate resource based on ghcr.io/<owner>/yayaw-worker:<sha>. It must not remain in the infrastructure stack that survives web releases.
  8. On a database clone, verify that drizzle.__drizzle_migrations accurately represents the already-applied SQL migrations. The rolling runner refuses to migrate a non-empty database with a missing or empty journal. Do not fabricate a baseline directly in production.
  9. Add COOLIFY_ROLLING_APPLICATION_UUID and NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the GitHub development environment. Generate the latter once with openssl rand -base64 32 and keep the same value across builds.
  10. Run Coolify Rolling Deploy for development. It pulls both images with retry, runs the Better Auth schema preparation, versioned Drizzle migrations, seed, and dynamic-data repair while the old app keeps serving, then updates the Docker Image Application. A failed deployment or public SHA smoke test restores the previous image tag automatically; database changes are not rolled back.
  11. After the web rollout succeeds, promote the separate worker resource to the emitted immutable yayaw-worker:<sha> tag. Keep the previous worker running until the replacement process is healthy; all worker changes must remain compatible with the migrated schema during this handoff.
  12. Validate an intentionally bad image or readiness failure and confirm that the old public SHA remains available. Then repeat the procedure for production.

After the new app and worker are healthy, change the existing Compose resource to docker-compose.coolify.infrastructure.yml. Set MINIO_MEDIA_FQDN to the public origin plus /media and MINIO_ORGANIZATION_LOGOS_FQDN to the same origin plus /organization-logos; Coolify creates the stable path routers to MinIO. Remove the old Caddy domain and assign the root domain to the Docker Image Application during the same handoff. The infrastructure file has no host port mappings and must not be redeployed for normal application releases.

Every migration used by this path must follow expand/contract: add compatible schema first, deploy code that tolerates both versions, and remove old schema only after all older containers and workers are gone. Automatic image rollback cannot undo a destructive database migration.

Coolify CI Environments

Self-hosted Coolify deployments are driven from GitHub Actions:

  • Pull requests deploy to a shared development Coolify application after the CI checks pass.
  • Pushes to main deploy to the production Coolify application after the CI checks pass.

Configure two GitHub environments with separate Coolify application UUIDs:

GitHub environmentRequired secret or variablePurpose
developmentCOOLIFY_URLCoolify API origin.
developmentCOOLIFY_API_TOKENToken allowed to update and deploy the development app.
developmentCOOLIFY_APPLICATION_UUIDShared development app UUID.
developmentCOOLIFY_ROLLING_APPLICATION_UUIDDocker Image Application UUID used by the manual rolling validation workflow.
developmentDEVELOPMENT_BASIC_AUTH_USERNAMEHTTP Basic username for the protected development app.
developmentDEVELOPMENT_BASIC_AUTH_PASSWORDHTTP Basic password for the protected development app.
developmentDEVELOPMENT_URL variableOptional smoke-test URL. Defaults to https://dev.yayaw.app; use an internal runner-reachable URL when the public DNS is not routed yet.
productionCOOLIFY_URLCoolify API origin.
productionCOOLIFY_API_TOKENToken allowed to deploy the production app.
productionCOOLIFY_APPLICATION_UUIDProduction app UUID.
productionCOOLIFY_ROLLING_APPLICATION_UUIDProduction Docker Image Application UUID after development validation.
development, productionBETTER_AUTH_SECRETBuild-time secret required by the Next.js Docker image build.
development, productionDATABASE_URLBuild-time database URL for server route evaluation.
developmentPOSTGRES_ROTATION_DATABASE_URLTemporary candidate URL used only by credential rotation and runtime synchronization. Keep DATABASE_URL on the currently working credential until the first rotated deployment succeeds.
development, productionNEXT_PUBLIC_* build secretsPublic client bundle values baked into the Docker image.
development, productionNEXT_SERVER_ACTIONS_ENCRYPTION_KEYStable base64 AES key embedded during next build so overlapping instances accept the same Server Actions payloads.
development, productionNEXT_BUILD_WORKERS variableBuild worker count. Defaults to 1 for stable self-hosted Docker builds.
development, productionNEXT_STATIC_PAGE_GENERATION_TIMEOUT variableStatic generation timeout. Defaults to 180.
development, productionCOOLIFY_SERVER_SSH_HOST variableOptional host override for loading images onto the Coolify VM. Defaults to 127.0.0.1 from the Mac mini runner.
development, productionCOOLIFY_SERVER_SSH_PORT variableOptional SSH port override. Defaults to 2222.
development, productionCOOLIFY_SERVER_SSH_USER variableOptional SSH user override. Defaults to yannis.
development, productionCOOLIFY_SERVER_SSH_KEY_PATH variable or COOLIFY_SERVER_SSH_KEY secretOptional SSH key override. The Mac mini runner defaults to /Users/yannis/.ssh/coolify_vm_ed25519.
development, productionCOOLIFY_SERVER_KNOWN_HOSTS_PATH variableOptional known-hosts override. Defaults to /Users/yannis/.ssh/known_hosts_coolify.

The CMS release preflight additionally synchronizes the database, private storage, signed-preview, personalization, and worker-maintenance contract to the Coolify app and worker. Configure these GitHub environment secrets:

  • S3_ACCESS_KEY_ID, S3_ENDPOINT, and S3_SECRET_ACCESS_KEY
  • CMS_PREVIEW_SIGNING_SECRET, CMS_PERSONALIZATION_RATE_LIMIT_SECRET, and CMS_PERSONALIZATION_FORM_CONTEXT_SECRET, each at least 32 characters; keep the form-context secret dedicated
  • optional STORAGE_TRANSFER_SIGNING_SECRET when storage transfer tokens should not share the preview-signing secret
  • DATABASE_URL in both environments
  • MINIO_ROOT_PASSWORD, POSTGRES_PASSWORD, and the temporary POSTGRES_ROTATION_DATABASE_URL and POSTGRES_ROTATION_PREVIOUS_PASSWORD in development

Configure STORAGE_PROVIDER=s3, STORAGE_MEDIA_BUCKET=media, STORAGE_MEDIA_STAGING_BUCKET=media-staging, STORAGE_CMS_FORM_BUCKET=cms-form-submissions, CMS_PERSONALIZATION_CLIENT_IP_HEADER=x-real-ip, PAGE_AI_WORKER_MAINTENANCE_MS=60000, S3_REGION=us-east-1, and S3_FORCE_PATH_STYLE=true as GitHub environment variables. STORAGE_PUBLIC_BASE_URL is optional when NEXT_PUBLIC_BASE_URL already names the same public origin. S3_SIGNED_PUBLIC_ENDPOINT is an optional variable for an explicitly configured direct-upload origin. Do not persist a rollout phase as a GitHub variable: the deployment and rollout workflows derive and validate the safe reader, freeze, or exact phase from the live fleet before synchronizing the corresponding runtime flags.

Prepare the development credentials once, outside the deployment:

database_password="$(openssl rand -hex 32)"
storage_password="$(openssl rand -hex 32)"
database_fingerprint="$(
  printf '%s' "$database_password" | shasum -a 256 | awk '{print $1}'
)"
rotation_database_url="$(
  printf 'postgresql://yayaw:%s@postgres:5432/yayaw' "$database_password"
)"

printf '%s' "$database_password" | gh secret set POSTGRES_PASSWORD --env development
printf '%s' "$rotation_database_url" \
  | gh secret set POSTGRES_ROTATION_DATABASE_URL --env development
printf '%s' "$storage_password" | gh secret set MINIO_ROOT_PASSWORD --env development
printf '%s' "$storage_password" | gh secret set S3_SECRET_ACCESS_KEY --env development
gh variable set CMS_DATABASE_CREDENTIALS_FINGERPRINT \
  --env development \
  --body "$database_fingerprint"

The database password is deliberately a raw 32-byte hexadecimal value so it does not require URL encoding. For an existing development volume, leave DATABASE_URL on the currently working credential while the PR images build. The workflow passes that active URL to the rotation step, which derives its raw password in memory without exposing it in logs. Set POSTGRES_ROTATION_PREVIOUS_PASSWORD explicitly only when the active URL uses an unsupported password encoding. The deployment job uses POSTGRES_ROTATION_DATABASE_URL only after the images exist: it tests the candidate credential over the private yayaw-postgres-development alias; when needed, it uses a permission-0600 remote env file to alter the role, retests the new credential, and synchronizes that candidate URL to Coolify. It stops before Coolify env mutation when neither credential works.

The legacy development Compose resource defaults its shared-network aliases to yayaw-postgres-development and yayaw-minio-development. Do not reuse either alias for production on the same Docker network: duplicate aliases make Docker DNS select an infrastructure container nondeterministically. If the legacy Compose file is intentionally used for production, set POSTGRES_COOLIFY_ALIAS=yayaw-postgres-production and MINIO_COOLIFY_ALIAS=yayaw-minio-production explicitly. The stable rolling infrastructure should continue using its Coolify-resolved service hostnames. Within the legacy development Compose resource, keep S3_ENDPOINT on http://minio:9000; the migration service waits for minio-init before the database seed uploads CMS assets. One-shot migration and storage-preflight containers run on the shared Coolify network instead; the rollout workflow supplies them with the unique http://yayaw-minio-development:9000 endpoint without changing the Compose runtime endpoint.

After the first rotated development deployment succeeds, promote the retained candidate URL and remove the one-time candidate:

printf '%s' "$rotation_database_url" \
  | gh secret set DATABASE_URL --env development
gh secret delete POSTGRES_ROTATION_DATABASE_URL --env development

Also delete POSTGRES_ROTATION_PREVIOUS_PASSWORD if the explicit fallback was configured.

For a fresh volume with no running app, set DATABASE_URL to the same candidate URL before the first build as well. Never replace only DATABASE_URL and POSTGRES_PASSWORD on an existing volume before the rotation workflow has completed.

The GitHub workflows also need repository package permissions: the image-build jobs use packages: write to publish ghcr.io/<owner>/yayaw-app:<sha> and ghcr.io/<owner>/yayaw-worker:<sha>, while the Coolify deploy jobs use packages: read to pull those images from the self-hosted runner.

The shared development app is intentionally mutable: each pull request force-updates the repository-managed coolify/development branch to the PR head SHA, updates the app's git_branch in Coolify to that stable branch, and deploys without forcing a rebuild. This avoids failures when a merged PR branch is deleted before Coolify clones the repository, while still letting BuildKit reuse layers across development and production deployments. GitHub Actions serializes development deploys through a single development-coolify-deploy concurrency group, so the shared environment stays predictable while new runs wait for the current deployment to finish.

Keep the development origin private. Prefer a Tailscale-only hostname or enable HTTP Basic authentication at the Coolify app level while still returning X-Robots-Tag: noindex, nofollow, noarchive. The smoke test runs from the self-hosted runner with the development Basic Auth secrets, so the runner must be able to reach the protected development URL.

Production Baseline

These values are required before a production deployment can serve the app correctly:

VariableWhere to get itValue guidance
NODE_ENVVercel runtimeUsually set by the platform. Do not override unless debugging a non-Vercel runtime.
NEXT_PUBLIC_BASE_URLVercel DomainsSet to the canonical public origin, for example https://yayaw.app.
BETTER_AUTH_SECRETSecret managerSet a stable long random value for Better Auth cookies and tokens.
DATABASE_URLPostgres providerUse the production pooled connection string when the provider exposes one.
BETTER_AUTH_TRUSTED_ORIGINSDeployment domainsAdd extra preview/local origins as a comma-separated list. The canonical host and www variant are derived from NEXT_PUBLIC_BASE_URL.
BETTER_AUTH_DEVICE_CLIENT_IDSDeployment envOptional comma-separated Better Auth Device Authorization client IDs. Defaults to kyber-desktop,kyber-web.
BETTER_AUTH_DEVICE_TRUSTED_ORIGINSDeployment envOptional comma-separated local/native origins allowed to use only the Device Authorization and bearer-token auth endpoints.
CADDY_HOST_PORTCoolify application envHost port for the compose Caddy container. Production defaults to 3080; set a different value such as 3081 for the shared development app.
MINIO_API_HOST_PORTCoolify application envHost port for MinIO's S3-compatible API. Production defaults to 9000; set a different value such as 9100 for the shared development app.
MINIO_CONSOLE_HOST_PORTCoolify application envHost port for the MinIO console. Production defaults to 9001; set a different value such as 9101 for the shared development app.
VERCEL_URLVercel runtimeSet automatically by Vercel. Keep it out of local .env unless reproducing platform behavior.

Optional database pool knobs:

VariableWhen to set it
DATABASE_POOL_MAXRaise only when the database provider can support more per-runtime connections.
DATABASE_IDLE_TIMEOUT_SECONDSTune idle lifecycle for non-serverless deployments.
DATABASE_MAX_LIFETIME_SECONDSTune connection rotation for long-lived runtimes.
DATABASE_CONNECT_TIMEOUT_SECONDSTune startup behavior for slow private networks.
DATABASE_STATEMENT_TIMEOUT_SECONDSAdd a server-side statement timeout for each database connection.
DATABASE_PREPARE_STATEMENTSKeep false for pooler/serverless production connections unless the provider supports prepared statements.
CMS_DASHBOARD_FULL_METRICSKeep false unless the database has been tuned for the full CMS publication and activity aggregates.

Database

DATABASE_URL is required by the application runtime, setup scripts, seeds, and Drizzle operations.

For the Coolify self-host deployment, schema sync and seeds run inside the compose migrate service before the app and worker start. Keep the production DATABASE_URL in Coolify or the orchestrator secret store so the setup step can resolve internal Docker service names such as postgres.

For hosted providers that do not run the self-host compose stack, execute the chosen Drizzle schema operation from a runner that can reach the production database before promoting the new app version.

For hosted Postgres providers:

  1. Create the production database.
  2. Open the provider connection string panel.
  3. Copy the pooled connection string when available.
  4. Store it as DATABASE_URL in Vercel Production and Preview, or in the self-host secret store.
  5. Use a separate development database for Vercel Development, local .env.local, or .env.self-host.

If the provider exposes both direct and pooled URLs, prefer the pooled URL for Vercel functions. The app already defaults to a conservative production pool of three connections per runtime to avoid exhausting serverless database limits.

Canonical Host and Auth

Set:

NEXT_PUBLIC_BASE_URL=https://yayaw.app
BETTER_AUTH_TRUSTED_ORIGINS=https://*.yayaw.app,https://*.vercel.app

NEXT_PUBLIC_BASE_URL drives canonical URLs, Better Auth base URL, OAuth metadata, sitemap/robots links, and generated absolute asset URLs.

Do not set NEXT_PUBLIC_SITE_URL; the application does not read it. Do not set BETTER_AUTH_URL for new deployments; Better Auth receives its base URL from NEXT_PUBLIC_BASE_URL.

The branch-backed preview domain is https://preview.yayaw.app and tracks the durable preview Git branch.

Retired .eu hosts may stay attached only as Vercel 308 redirects to their .app replacements, such as yayaw.eu and www.yayaw.eu to yayaw.app. They must not serve dashboard/auth traffic or appear in BETTER_AUTH_TRUSTED_ORIGINS.

Stripe Billing

Stripe has three required secrets when billing is enabled:

VariableWhere to get itNotes
STRIPE_SECRET_KEYStripe Dashboard, Developers, API keysUse sk_live_... in production and sk_test_... in staging/local test environments.
STRIPE_WEBHOOK_SECRETStripe Dashboard, Developers, Webhooks, subscription endpoint signing secretUsed by the Better Auth Stripe webhook endpoint.
STRIPE_ONE_TIME_WEBHOOK_SECRETStripe Dashboard, Developers, Webhooks, one-time endpoint signing secretUsed by the custom one-time checkout webhook endpoint.

Create two Stripe webhook endpoints for production:

https://yayaw.app/api/auth/stripe/webhook
https://yayaw.app/api/billing/stripe/webhook

Use the signing secret from the first endpoint for STRIPE_WEBHOOK_SECRET and the signing secret from the second endpoint for STRIPE_ONE_TIME_WEBHOOK_SECRET. Do not reuse one endpoint secret for the other endpoint.

The subscription endpoint should receive subscription checkout and billing lifecycle events. The one-time endpoint only needs checkout.session.completed for lifetime purchases.

Billing product prices are managed from /dashboard/admin/billing-products or through the control plane MCP. Stripe price IDs are stored in the database after catalog sync; they are not deployment secrets.

These values are optional, non-secret URLs shown on /dashboard/organization/code-access after a paid purchase unlocks code access:

VariableExampleWhen to use it
BILLING_CODE_ACCESS_REPOSITORY_URLhttps://github.com/Yayaw-eu/yayawLink to the private repository after GitHub access is granted.
BILLING_CODE_ACCESS_DOWNLOAD_URLhttps://github.com/Yayaw-eu/yayaw/releasesLink to release artifacts or archive downloads.
BILLING_CODE_ACCESS_DOCUMENTATION_URLhttps://docs.yayaw.appLink to setup or usage documentation.
BILLING_CODE_ACCESS_SUPPORT_URLmailto:support@yayaw.appLink to support for access problems.

Leave a value empty when that deliverable requires manual provisioning.

GitHub Code Access

Production repository access should use a GitHub App, not a personal access token. The app lets Yayaw invite paid customers to the configured repository with read-only pull access without storing a human user's GitHub credentials.

Create the GitHub App

Create the app from the account that owns the private repository:

GitHub organization -> Settings -> Developer settings -> GitHub Apps -> New GitHub App

Recommended app values:

FieldValue
GitHub App nameYayaw Code Access
Homepage URLThe production app URL, for example https://yayaw.app
WebhookDisabled unless a future feature needs GitHub callbacks
Where can this GitHub App be installedOnly on this account

Repository permissions:

PermissionAccess
AdministrationRead and write
MetadataRead-only, automatically granted by GitHub

The Administration: Read and write permission is required because Yayaw calls the GitHub collaborator API to invite the requested username to the repository. The app does not need Contents permissions for the current flow because the customer receives normal repository access through their own GitHub account.

Install the GitHub App

After creating the app:

  1. Generate a private key from the app settings and download the .pem file.
  2. Install the app on the owner account or organization.
  3. Select only the repository that paid customers should access.
  4. Copy the App ID from the app settings.
  5. Copy the installation ID from the installation URL.

The installation URL usually looks like this for an organization install:

https://github.com/organizations/Yayaw-eu/settings/installations/12345678

The final numeric segment is the GitHub App installation ID.

Store the GitHub App secret

Store the private key in the target environment. For Vercel:

vercel env add BILLING_CODE_ACCESS_GITHUB_APP_PRIVATE_KEY production
vercel env add BILLING_CODE_ACCESS_GITHUB_APP_PRIVATE_KEY preview

The Vercel dashboard is the safest place to paste a multiline PEM value for the hosted provider. For a local .env.local or self-host secret file, either paste the multiline key in quotes or escape newlines on one line:

printf 'BILLING_CODE_ACCESS_GITHUB_APP_PRIVATE_KEY="%s"\n' "$(perl -0pe 's/\n/\\n/g' yayaw-code-access.pem)" >> .env.local

The runtime accepts escaped \n sequences and converts them back to real newlines before signing the GitHub App JWT. If a deployment platform flattens the PEM into one line with spaces between the header, body, and footer, Yayaw normalizes it back into PEM format at runtime.

Configure non-secret GitHub values

Prefer the admin UI for non-secret values:

/dashboard/admin/billing-settings

Set:

Admin settingValue
EnabledOn
RepositoryYayaw-eu/yayaw or the exact target repository
GitHub App IDThe numeric App ID from the GitHub App settings
GitHub App installation IDThe numeric installation ID from the installation URL

Environment fallbacks are available for deployments that need boot-time config:

BILLING_CODE_ACCESS_GITHUB_REPOSITORY=Yayaw-eu/yayaw
BILLING_CODE_ACCESS_GITHUB_APP_ID=123456
BILLING_CODE_ACCESS_GITHUB_APP_INSTALLATION_ID=12345678
BILLING_CODE_ACCESS_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"

Local or staging token fallback

Use BILLING_CODE_ACCESS_GITHUB_TOKEN only for local or staging environments where a GitHub App is not available:

BILLING_CODE_ACCESS_GITHUB_REPOSITORY=Yayaw-eu/yayaw
BILLING_CODE_ACCESS_GITHUB_TOKEN=github_pat_...

The token owner must be allowed to add collaborators to the repository. For a fine-grained token, grant access only to the target repository and include the repository administration permission needed to invite collaborators. Do not use the token fallback for production unless there is an incident workaround and a rotation plan.

Email

RESEND_API_KEY enables invitation, reset, magic-link, and billing transactional emails.

To retrieve it:

  1. Verify the sending domain in Resend for production email.
  2. Create a server-side API key in Resend.
  3. Store it as RESEND_API_KEY in the target deployment environment.
  4. Send a test email from the transactional email admin surface after deploy.

Set EMAIL_SENDER to a verified sender on the Resend domain, for example team@yayaw.app. Set EMAIL_SUPPORT to the support address shown inside email templates, and EMAIL_USERNAME to the sender display name. These variables are bootstrap defaults and fallbacks; after the first admin user can sign in, Admin > Site Settings > Email takes priority at runtime.

Missing RESEND_API_KEY does not break billing webhook processing, but it does skip transactional emails.

OAuth Providers

These values are optional and only required when the matching provider is enabled:

ProviderVariablesWhere to get them
GitHub OAuthGITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETGitHub OAuth App settings. This is separate from the GitHub App used for code access.
Google OAuthGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGoogle Cloud Console OAuth client.
Facebook OAuthFACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRETMeta developer app settings.

Use the production origin from NEXT_PUBLIC_BASE_URL when configuring OAuth redirect URIs.

Object Storage

Media storage is optional until upload, thumbnail, logo, or generated-image features are used. Set STORAGE_PROVIDER=s3 for MinIO, S3, R2, or another S3-compatible provider.

Common variables:

VariableWhere to get it
STORAGE_PROVIDERSet to s3.
STORAGE_MEDIA_BUCKETBucket name for media assets. Defaults to media.
STORAGE_MEDIA_STAGING_BUCKETPrivate bucket for staged direct uploads. The bundled self-host setup uses media-staging.
STORAGE_CMS_FORM_BUCKETDurable private bucket for customer form attachments. The bundled self-host setup uses cms-form-submissions.
STORAGE_PUBLIC_BASE_URLPublic object origin for S3-compatible storage, serving /<bucket>/<key>. Use the app or CDN origin only when those bucket prefixes are routed to the object store.
STORAGE_TRANSFER_SIGNING_SECRETOptional dedicated secret for opaque same-origin private transfer grants. It falls back to the preview or auth secret and must resolve to at least 32 characters in production.
CMS_PERSONALIZATION_FORM_CONTEXT_SECRETRequired dedicated secret for expiring public CMS form provenance. Generate at least 32 random characters and never expose it to the browser or CMS content.
S3_SIGNED_PUBLIC_ENDPOINTOptional explicit public S3 API origin for direct signed uploads when the app host cannot accept the 250 MB CMS request limit. Never set the internal Docker endpoint.

For same-origin or CDN-fronted S3 storage, proxy every public bucket prefix to the object store before the app fallback. The built-in public buckets are /media/* for uploaded media and /organization-logos/* for organization logos. If STORAGE_PUBLIC_BASE_URL points directly to a dedicated object-store origin, that origin must serve the same /<bucket>/<key> paths.

Create the staging and form buckets before enabling their writers and do not grant them anonymous read access. Limit provider lifecycle cleanup to the staging bucket or staging object prefix; the Page AI worker owns the durable 180-day form-photo retention ledger and retries interrupted deletions. Set CMS_PERSONALIZATION_CLIENT_IP_HEADER=x-real-ip for the bundled Caddy ingress, which overwrites that header, or use another proxy-owned single-value header only after verifying the ingress strips client input. Keep the worker running with PAGE_AI_QUEUE_DRIVER=db-worker in self-hosted environments.

Private transfers use an opaque same-origin application grant by default, so S3_ENDPOINT can remain private. On a platform with a lower inbound-body limit, set S3_SIGNED_PUBLIC_ENDPOINT to a dedicated safe HTTPS S3 API origin; only uploads switch to a conditional direct PUT and private downloads remain behind the app proxy. Allow the app origin, PUT, Content-Type, Content-Length, and If-None-Match in that bucket's CORS policy. Promoted staging bytes remain as a no-overwrite tombstone through grant expiry and a 24-hour settlement window. The reaper repeats deletion and records cleanup only after its final delete.

Roll out exact prototype writers in this order:

  1. Deploy the compatible reader with CMS_LEGACY_PROTOTYPE_WRITES_ENABLED=true and CMS_PROTOTYPE_RUNTIME_V2_WRITES_ENABLED=false.
  2. Optionally run the manual CMS Prototype Runtime Rollout workflow with reader and the full SHA already deployed to prove the reader fleet.
  3. Run the same trusted workflow with exact. It verifies/creates the private buckets, synchronizes freeze, redeploys every typed app/worker resource on the requested full SHA, and records the live resource UUID, deployment UUID, image digest, image name, commit, and complete active container-ID set in a version-1 durable receipt. Every active container must independently expose the expected SHA plus the legacy, v2, staged-upload, and rollout-operation environment values.
  4. After revalidating that no newer deployment replaced the drain evidence, the workflow moves the receipt from issued to activating, synchronizes legacy false, v2 true, staged uploads true, and CMS_PROTOTYPE_ROLLOUT_OPERATION_ID, then redeploys and revalidates every resource again. The exact deployment UUID must be new while its typed resource UUID, image digest, image name, and full SHA remain identical.
  5. The workflow verifies the final runtime state, reinspects every exact container, and passes that fresh JSON directly to receipt consumption in the same shell step. Consuming the receipt activates the database floor before any design gate is written. Only the current consumed operation may authorize a new prototypeGateV2.

The workflow always checks out its control-plane scripts from current trusted main; the requested deployment SHA is evidence, never executable workflow code. The durable-receipt dispatcher is copied from that trusted checkout, verified by checksum, and executed in a hardened one-shot container attached to the private Coolify Docker network using the worker image built from that same control-plane SHA. Its entrypoint is overridden and its image ID is pinned before database access. Keep Postgres private; the rollout does not require a public database URL or port. If a run fails after activating, the failure handler can only restore and redeploy freeze. Resume the same operation explicitly with resume_operation_id and its confirmation string. A resume requires the same environment, repository, deployment SHA, typed resources, and images, plus a fresh live drain attempt with its own expiry. The immutable parent keeps the control-plane SHA that issued it; a resumed child attempt records and validates the current trusted-main control-plane SHA. It cannot issue a competing active receipt or fall back to reader.

If the fleet has already advanced to a strict descendant SHA while an older receipt remains activating, do not resume or roll back the old target. Run exact on the live descendant with both superseded_operation_id and superseded_deploy_sha, plus exact:<new-sha>:supersede:<old-sha>:<old-operation-id> as confirmation. The workflow proves the Git ancestry, redeploys and revalidates the descendant in freeze, then atomically creates and activates the replacement receipt while linking the old one as terminal superseded. The replacement must preserve the typed resource UUIDs and prove new deployment UUIDs. Supersession never advances the runtime floor; only consumption of the replacement does.

Normal automatic production deployment is reader-only. The shared development PR deployment resolves its phase from trusted-main scripts: it keeps reader/bootstrap as reader, or preserves freeze only when both Coolify configuration and every live app/worker container already prove freeze. It cannot initiate freeze from reader or exact, and it never activates exact.

For a later renderer upgrade in development:

  1. Run CMS Prototype Runtime Rollout with freeze against the currently deployed full SHA.
  2. Re-run the target PR deployment. Under the shared development-coolify-deploy lock, it preserves freeze, deploys the PR's new immutable SHA, and then proves that every live app/worker container exposes that SHA and the freeze flags. A single development Compose resource is proven as one deployment containing both expected images, with a stable aggregate image digest and the complete app/worker container-ID set.
  3. Run CMS Prototype Runtime Rollout with exact on that same PR SHA. It creates and consumes the next receipt, advancing the current rollout operation while retaining the first floor operation.

For a later renderer upgrade in production:

  1. Run CMS Prototype Runtime Rollout with freeze against the currently deployed full SHA.
  2. Dispatch Coolify Rolling Deploy manually with the target full main-history SHA and freeze-upgrade:<full-sha>. It refuses to build unless the configured state and every live app/worker container are already frozen. Build, migration, worker rollout, and app rollout then remain under the shared freeze lock, and the final step proves the target SHA and images are still live-frozen.
  3. Run CMS Prototype Runtime Rollout with exact on that same target SHA. This creates and consumes the next receipt, advancing the current rollout operation while retaining the first floor operation.

The normal job never simulates this transition. Once the floor is active, never use reader as a reset or re-enable legacy writers.

Migrations 0047_cms_prototype_runtime_floor and 0049_cms_prototype_rollout_receipt_supersession make that boundary durable. They persist immutable parent receipts and run attempts, constrain each one-way issuedactivatingconsumed state or the explicit terminal recovery branch activatingsuperseded, and lock design-session writers while safely backfilling any pre-existing exact gate with an irreversible synthetic first/current operation. Receipt consumption activates the independent singleton. Its first activation timestamp and first rollout operation are immutable; its first design-context ID remains null until the first new exact gate binds it and is immutable thereafter. Later consumed receipts may only advance current_v2_rollout_operation_id. Existing exact sessions can continue ordinary updates without replaying the transaction-local operation, while a newly added exact gate must carry the current operation. The synthetic backfill string is never sufficient authorization: the current operation must also resolve to a real consumed receipt before another exact gate can be added. The floor cannot be deleted, truncated, reset, or reversed by expiring/deleting the originating session. Database triggers and same-transaction writer guards serialize legacy session, component, page, review, publication, and media writes against that singleton. Resetting an environment flag therefore cannot reopen legacy mutations. Legacy reads, pure dry-runs, completed-upload idempotent reads, cancellation/expiration, and media cleanup remain available. A floor-rejected media commit removes its promoted object, while prepared-upload cleanup remains retryable by the reaper.

The manual workflow is also the rolling-binary safety boundary: every resource must run the immutable SHA in freeze before any resource enters exact. Do not edit these flags directly or skip the workflow, because older binaries do not contain every application-level floor guard.

The worker-image storage preflight needs HeadBucket, optional CreateBucket, GetBucketPolicy, GetBucketAcl, PutObject, GetObject, and DeleteObject on the staging and form buckets. It never prints storage credentials or full provider errors.

During setup, bun run seed uploads the default global site-variable assets to the configured media bucket under global/site-variables/* and writes those public URLs into the site-variables/main global-data entry. Existing custom site-variable asset URLs are preserved when the seed is re-run.

S3-compatible storage variables:

VariableWhere to get it
S3_ENDPOINTProvider endpoint, for example http://minio:9000.
S3_REGIONProvider region. Use us-east-1 for MinIO unless configured otherwise.
S3_ACCESS_KEY_IDServer-side storage credential.
S3_SECRET_ACCESS_KEYServer-side storage credential.
S3_FORCE_PATH_STYLESet true for MinIO and most local S3-compatible endpoints.

Never expose S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY to the browser or a NEXT_PUBLIC_* variable.

S3_SIGNED_PUBLIC_ENDPOINT is optional and is never inferred from S3_ENDPOINT. When set in production, it must use HTTPS with a public, multi-label DNS hostname and no credentials, query, or fragment. It enables only signed direct PUT uploads; downloads continue through the same-origin app proxy. Configure bucket CORS for the app origin, PUT, and the Content-Type, Content-Length, and If-None-Match headers before setting this variable. STORAGE_TRANSFER_SIGNING_SECRET optionally gives opaque transfer tokens a dedicated secret; otherwise the runtime deliberately falls back to CMS_PREVIEW_SIGNING_SECRET, then BETTER_AUTH_SECRET. The effective production secret must contain at least 32 characters.

Public Domains

Organization public domains use PUBLIC_DOMAIN_PROVIDER.

VariablePurpose
PUBLIC_DOMAIN_PROVIDERvercel for Vercel project-domain management or manual-dns for self-hosted DNS verification.
APP_MANAGED_HOSTSComma-separated app-owned hosts that customers cannot claim.
RESERVED_PUBLIC_DOMAIN_SUFFIXESAdditional suffixes that customers cannot claim. .vercel.app is always reserved.
PUBLIC_DOMAIN_CNAME_TARGETManual DNS CNAME target shown to operators.
PUBLIC_DOMAIN_IPV4_TARGETSComma-separated manual DNS A-record targets.
PUBLIC_DOMAIN_TXT_PREFIXTXT verification prefix, default _yayaw.
VERCEL_TOKENRequired only for the Vercel provider.
VERCEL_PROJECT_IDRequired only for the Vercel provider.
VERCEL_TEAM_IDOptional Vercel team scope.

manual-dns generates a TXT ownership challenge and verifies public DNS. It does not automate DNS-provider changes or TLS certificate issuance.

OpenAI

AI generation features require:

OPENAI_API_KEY=

Create the key from the OpenAI platform API key page and store it server-side in the target deployment environment. The key enables component AI fallback, page AI generation, and image generation when the corresponding feature flags and runtime settings are on.

Optional AI runtime toggles:

VariableDefaultPurpose
OPENAI_COMPONENTS_AI_FALLBACKtrueEnables AI fallback for component inference and classification.
OPENAI_IMAGE_GENERATION_ENABLEDtrueEnables page-builder image generation when an API key exists.
OPENAI_IMAGE_MODELgpt-image-1.5Image generation model.
PAGE_AI_QUEUE_DRIVERAutoUse direct locally, vercel-queue on Vercel, or db-worker for a long-lived worker. Production auto-detects Vercel and otherwise defaults to db-worker.
PAGE_AI_DEEP_REFINEMENTfalseInternal quality toggle, not exposed in admin settings.
PAGE_AI_WORKER_POLL_MS1500Poll interval for the database worker driver.
PAGE_AI_WORKER_MAINTENANCE_MS60000Interval for durable personalization retention, orphan cleanup, and abuse-bucket pruning.
PAGE_AI_WORKER_IDEmptyOptional identifier for a long-lived worker.

Analytics

Choose one analytics provider and one capture mode:

NEXT_PUBLIC_ANALYTICS_PROVIDER=umami
NEXT_PUBLIC_ANALYTICS_CAPTURE_MODE=hybrid

Supported providers are posthog, umami, and none. Capture mode can be hybrid, server, or client. hybrid is the default and records reliable server-side billing/auth events while keeping browser product analytics. server does not render browser analytics scripts; it sends CMS page views and conversion events from the app server instead. client keeps browser-only capture.

Server-side conversion events include checkout_started, purchase_completed, subscription_started, subscription_updated, subscription_cancel_scheduled, subscription_canceled, and payment_failed. Billing events include revenue, value, currency, plan, product_key, and Stripe IDs for reconciliation. Stripe test-mode billing events are still tracked with billing_mode=test and is_test_data=true, but they do not count as conversion, revenue, or value unless the Track Stripe Test Billing Analytics admin setting is enabled. The original test amount is kept in test_amount_cents and test_revenue for debugging.

PostHog

Client capture and feature flags use public PostHog values:

NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL=false

Server-side dashboard analytics use private PostHog values:

POSTHOG_PERSONAL_API_KEY=
POSTHOG_PROJECT_ID=
POSTHOG_API_HOST=https://eu.posthog.com
POSTHOG_ORG_ID_PROPERTY=organization_id
POSTHOG_FLAG_LOOKUP_TIMEOUT_MS=

Retrieve the public project key and project ID from the PostHog project settings. Create a personal API key with enough access for the Query API and store it as POSTHOG_PERSONAL_API_KEY. Keep POSTHOG_ORG_ID_PROPERTY=organization_id unless the analytics event property changes in the product. Set POSTHOG_FLAG_LOOKUP_TIMEOUT_MS only when server-side PostHog flag lookups need a timeout other than the default 1500 milliseconds.

Umami

Client capture uses public Umami values:

NEXT_PUBLIC_UMAMI_HOST_URL=https://analytics.example.com
NEXT_PUBLIC_UMAMI_SCRIPT_URL=
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_DOMAINS=
NEXT_PUBLIC_UMAMI_AUTO_TRACK=true

Server-side dashboard reads and server capture use private Umami values:

UMAMI_API_URL=https://analytics.example.com
UMAMI_WEBSITE_ID=
UMAMI_API_TOKEN=
UMAMI_API_KEY=
UMAMI_USERNAME=
UMAMI_PASSWORD=
UMAMI_CMS_EVENT_PAGE_SIZE=1000

Use either UMAMI_API_TOKEN (or the legacy alias UMAMI_API_KEY) or UMAMI_USERNAME plus UMAMI_PASSWORD for dashboard data reads. Server event capture uses Umami /api/send, so it only needs UMAMI_API_URL and UMAMI_WEBSITE_ID.

Control Plane MCP

Production MCP does not require an environment variable inside the app runtime. Clients connect to /api/mcp with a Better Auth API key or OAuth access token.

Local stdio testing can use:

YAYAW_MCP_API_KEY=
YAYAW_MCP_LOCAL_USER_ID=

Create, inspect, and revoke MCP keys with:

bun run mcp:key

Store trusted client keys outside the repository and pass them to the client as YAYAW_MCP_API_KEY.

Dynamic Data Runtime Transforms

Set DYNAMIC_DATA_RUNTIME_TRANSFORM_SECRET only when deployed dynamic runtime routes use hmac_sha256 value transforms, such as a pairing code transformed into a stored code_hash.

DYNAMIC_DATA_RUNTIME_TRANSFORM_SECRET=$(openssl rand -hex 32)

Store the value as a server-only secret in the orchestrator. Rotating it changes future HMAC output, so rotate alongside a product-specific data migration or pairing-session expiry window.

Maintenance Mode

Maintenance mode is optional:

MAINTENANCE_MODE=false
MAINTENANCE_MODE_END_DATE=

Set MAINTENANCE_MODE=true only when the app should serve the maintenance surface. Use MAINTENANCE_MODE_END_DATE for operator-facing status context.

Deployment Metadata

Vercel deployments get runtime metadata from Vercel automatically. Self-hosted deployments can provide static metadata:

VariablePurpose
DEPLOYMENT_PROVIDERvercel, static, or local; empty auto-detects.
DEPLOYMENT_URLPublic deployment URL outside Vercel.
DEPLOYMENT_ENVEnvironment label, such as production or preview.
DEPLOYMENT_GIT_COMMIT_SHACommit SHA for dashboard/control-plane status.
DEPLOYMENT_GIT_COMMIT_REFCommit ref or branch name.

Deployment Checklist

Before promoting a deployment:

  1. The target environment has every required variable from .env.example or .env.self-host.example.
  2. Preview has either production-equivalent provider values or explicit staging provider values.
  3. NEXT_PUBLIC_BASE_URL matches the public HTTPS origin for the environment.
  4. Stripe has two webhook endpoints and each endpoint secret is mapped to the matching environment variable.
  5. GitHub code access has either admin settings plus BILLING_CODE_ACCESS_GITHUB_APP_PRIVATE_KEY, or explicit environment fallbacks for repository, App ID, installation ID, and private key.
  6. Resend, PostHog, S3, OpenAI, and OAuth variables are present only when those features are enabled.
  7. Coolify or the orchestrator has the runtime DATABASE_URL used by the compose migrate service, and GitHub environments have the build-time DATABASE_URL value needed by next build.
  8. A fresh deployment or container restart was triggered after the last environment variable change.
  9. Run the local quality gates before merging deployment config changes:
bun run check
bun run docs:generate
bun run docs:check-links
bun run docs:check-translations
bun run docs:llm:check
bunx tsc --noEmit
bun run build

Provider References