Deployment
Deploy the static frontend, portable API image, and PostgreSQL with release-gated migrations.
Deployment
The foundation supports Railway, Dokploy, and three OpenTofu-managed backend clouds. Vercel and Cloudflare remain static frontend hosts only.
Start with Go live when coordinating product, provider, backup, and browser proof. Use this page for the supported infrastructure and release mechanics.
| Backend | Native frontend | External frontend | API | PostgreSQL |
|---|---|---|---|---|
| AWS | S3 + CloudFront | Vercel SPA or Cloudflare Workers Static Assets | ECS Fargate + HTTPS ALB | RDS PostgreSQL 18 |
| GCP | GCS + Cloud CDN | Vercel SPA or Cloudflare Workers Static Assets | Cloud Run | Cloud SQL PostgreSQL 18 |
| Azure | Static Web Apps | Vercel SPA or Cloudflare Workers Static Assets | Container Apps | Flexible Server PostgreSQL 18 |
OpenTofu is included for every buyer and becomes more useful as environments and teams grow. The repository supports OpenTofu 1.12.5 only. It does not claim Terraform compatibility.
Choose a target
Use Railway for the shortest managed deployment path. Use Dokploy when an operator owns the server, database backups, updates, and monitoring. Choose AWS, GCP, or Azure when provider-native identity, networking, observability, and managed PostgreSQL are required.
Each environment has one backend cloud, one object-storage provider, and one frontend target. AWS defaults to S3 and may select R2. GCP defaults to GCS and may select R2. Azure uses R2; Azure Blob storage is not supported.
nativecreates the cloud-native static host.vercelaccepts an existing exact production origin and creates no Vercel resources.cloudflareaccepts an existing exact production origin and creates no Cloudflare resources.
Multi-cloud means portability between independent environments. It does not mean active-active operation, cross-cloud replication, or shared state.
The application creator asks Set up cloud deployment now?. Choose yes to select the cloud, storage
provider, frontend host, environment, region, sibling production domains, and cost-first or HA
profile. It writes deployment/config.json, generates an exact protected-environment checklist, and
removes unused provider roots and frontend adapters. The selection is deliberately locked so a later
agent cannot silently turn one environment into a different cloud.
Choose no to keep every provider while the product is still local. Configure and prune them later from the generated repository with:
pnpm deploy:configureRestore removed paths from a fresh licensed release before changing a locked provider.
Delivery files
apps/server/Dockerfilebuilds the shared production image.apps/server/railway.tomldefines the Railway release and service commands.deploy/dokploy/compose.ymldefines the one-shot migration and API services.apps/webcontains the static SPA and its native, Vercel, and Cloudflare adapters..github/workflows/deploy-cloud.ymlimplements the cloud OIDC release sequence.
Runtime contract
The compiled image exposes two deployment commands:
./server migrate
./server servemigrate resolves the external migration directory, takes a PostgreSQL advisory lock, applies each
pending dbmate up section transactionally, and exits. Concurrent invocations serialize. serve
validates its external email templates, connects to PostgreSQL, and starts HTTP without changing the
schema.
/health is process liveness and never queries PostgreSQL. /ready returns 200 only after
startup resources are loaded and PostgreSQL answers a probe. Managed load balancers and container
health checks use /ready for traffic admission.
Application endpoint limits use PostgreSQL atomic fixed windows, so replicas share one budget. Better Auth keeps its own database limiter. Set pool limits so:
maximum replicas × (APP_DB_POOL_MAX + AUTH_DB_POOL_MAX) ≤ 80% of database connectionsEvery cloud module enforces this calculation from its declared connection capacity.
OpenTofu layout
infra/
bootstrap/{aws,gcp,azure}/
modules/{aws,gcp,azure}/
roots/{aws,gcp,azure}/
tests/verify.sh
deployment/
config.json
README.mdEach root has an independent backend, provider constraints, committed provider lock, example environment values, and mocked cost-first and HA tests. Environments use separate state paths or state buckets, never OpenTofu workspaces.
deployment/config.json is the committed, non-secret source for common inputs.
deployment/README.md lists the state, OIDC, DNS, secret-manager, and GitHub environment work that
requires the buyer's cloud authorization.
Verify all roots with:
pnpm verify:infraThis runs tofu fmt -check -recursive, backend-free initialization with a read-only lock,
validation, and mocked tofu test plans for every root.
State bootstrap
Run the matching bootstrap once with a tightly controlled administrator identity:
infra/bootstrap/aws/README.mdcreates a private, encrypted, versioned S3 bucket using nativeuse_lockfilelocking.infra/bootstrap/gcp/README.mdcreates a uniform-access, versioned GCS bucket dedicated to one environment. GCS provides native state locking.infra/bootstrap/azure/README.mdcreates a private, versioned Blob container. The backend uses native blob leases.
Copy the output into backend.hcl outside version control. Real backend.hcl, .tfvars,
.tfstate, .terraform/, and generated cloud credentials are excluded from buyer scaffolds. Only
examples and provider locks ship.
Common inputs and outputs
All roots accept project_name, environment, region, storage_provider, frontend_target,
external_frontend_origin, api_domain, frontend_domain, image_digest, traffic_enabled,
ha_enabled, API sizing, replica limits, pool limits, database sizing, backup retention, log
retention, deletion protection, and an optional alert email. The DNS zone input is provider-specific
because provider zone identifiers are not interchangeable.
The AWS and GCP roots provision native application storage only when storage_provider is s3 or
gcs. They create separate staging and public buckets, exact-origin CORS, one-day staging expiry,
public profile-image reads, and workload-identity permissions. When storage_provider is r2, the
operator supplies R2 configuration through the cloud's runtime secret container. See
Storage.
External frontends require external_frontend_origin. It must be the exact stable production
origin, not a preview URL. Guided setup derives it from the selected frontend domain. The workflow
supplies the immutable sha256: OCI image_digest; buyers do not guess the first digest.
traffic_enabled remains false until the first migration succeeds. Provider-specific SKU overrides
fail at provider planning or apply with the selected region and requested SKU visible; if a default
database SKU is unavailable, set the documented db_instance_class, db_tier, or db_sku_name
override instead of silently accepting a larger tier.
Every root returns:
api_originandfrontend_origin;container_repository;api_service_id,migration_job_id, and the provider-specificrelease_target;database_secret_id, never its value;dns_validation_records;native_frontend_targetandnative_frontend_deployment;observability_url.
AWS quickstart
Guided setup keeps the AWS root and removes the others. Bootstrap state once, put the Route 53 zone
or existing certificate identifiers in the protected TFVARS_JSON overlay, then use the deployment
workflow. For direct OpenTofu inspection:
cd infra/roots/aws
tofu init -backend-config=/protected/aws-backend.hcl
tofu plan -var-file=/protected/aws.tfvarsCost-first runs one public-subnet Fargate task reachable only from the ALB security group and a
private, encrypted, single-AZ db.t4g.micro RDS instance. It creates no NAT gateway. HA runs two
API tasks in private subnets, one NAT gateway per availability zone, Multi-AZ RDS, and an ECS
deployment circuit breaker. The native frontend is a private S3 bucket read only through CloudFront
Origin Access Control, with immutable asset caching and no-cache HTML.
GCP quickstart
Guided setup keeps the GCP root and removes the others. Bootstrap state once, put the project and
Cloud DNS inputs in the protected TFVARS_JSON overlay, then use the deployment workflow. For
direct OpenTofu inspection:
cd infra/roots/gcp
tofu init -backend-config=/protected/gcp-backend.hcl
tofu plan -var-file=/protected/gcp.tfvarsCost-first runs Cloud Run from zero to three instances and a private-IP zonal db-f1-micro Cloud
SQL instance. HA keeps at least one Cloud Run instance warm, uses regional Cloud SQL HA, and enables
point-in-time recovery. Cloud Run uses Direct VPC egress. The API sits behind the external HTTPS
load balancer so the gcp-cloud-run proxy profile can validate the provider-appended forwarding
shape. The native frontend uses a versioned GCS bucket, a backend bucket, Cloud CDN, and managed
TLS.
Azure quickstart
Guided setup keeps the Azure root and removes the others. Bootstrap state once, put Azure DNS and
Key Vault identifiers in the protected TFVARS_JSON overlay, then use the deployment workflow. For
direct OpenTofu inspection:
cd infra/roots/azure
tofu init -backend-config=/protected/azure-backend.hcl
tofu plan -var-file=/protected/azure.tfvarsCost-first runs Container Apps from zero to three replicas and a private B_Standard_B1ms Flexible
Server. HA keeps two API replicas warm and requests zone redundancy for the Container Apps
environment and PostgreSQL. Regions without the requested zone or SKU support fail visibly and
require an explicit region or tier override. The native frontend uses Static Web Apps and the
included staticwebapp.config.json for React Router fallback and hashed-asset caching.
Frontend selection
The browser has one provider-neutral backend variable:
VITE_APP_ENV=production
VITE_API_URL=https://api.example.com
VITE_TERMS_URL=https://example.com/terms
VITE_PRIVACY_URL=https://example.com/privacyNative
The protected deployment workflow builds apps/web after the API is ready. AWS syncs hashed assets
with immutable caching and invalidates only index.html. GCP synchronizes the bucket, sets asset
and HTML cache metadata, and refreshes the HTML CDN entry. Azure retrieves the Static Web Apps
deployment credential at runtime through Azure OIDC and does not store it as a repository secret.
Vercel SPA
Create a Vercel project rooted at apps/web, keep source files outside the root available to the
build, and use the included apps/web/vercel.json:
- install:
npm install --global pnpm@11.18.0 && pnpm install --frozen-lockfile; - build:
pnpm run build:prod; - output:
dist; - production domain: the exact
frontend_domainsupplied to OpenTofu; VITE_API_URL: theapi_originoutput.
Prefer Vercel Git integration. Vercel Functions and Vercel databases are unsupported.
Cloudflare Workers Static Assets
Create a Workers project rooted at apps/web and use apps/web/wrangler.toml. It contains only the
dist asset directory and not_found_handling = "single-page-application"; there is no Worker API
entry point. Prefer Cloudflare Git integration. An API-token deployment is optional, but the token
must live in a protected deployment environment. Cloudflare API Workers, D1, Hyperdrive, Containers,
Queues, and Durable Objects are unsupported.
Documentation site
apps/docs is a separate Next.js application and is not deployed by the product frontend or
OpenTofu paths. It requires its own Node-compatible host and public-origin verification when a buyer
chooses to publish it. Follow Operate the documentation site; do not point the SPA
deployment workflow at the docs workspace.
DNS, CORS, and same-site auth
Production uses stable HTTPS names such as app.example.com and api.example.com. Keep them under
the same registrable site so Better Auth cookies do not depend on third-party-cookie exceptions.
OpenTofu configures the exact frontend origin in FRONTEND_URL; Hono CORS and Better Auth trusted
origins reject any other origin.
Preview domains never use the production API. Give previews a stable staging alias and staging API under the same registrable site.
TRUSTED_PROXY_PROFILE is one of direct, railway, dokploy, aws-alb, gcp-cloud-run, or
azure-container-apps. The runtime discards a user-supplied internal client-IP header and rebuilds
it from the selected ingress shape. Do not expose the container through another public path.
Secrets
Never put Better Auth, email, billing, Sentry, R2 credentials, OAuth, or secret provider values in
committed .tfvars. OpenTofu creates the cloud secret container and runtime identity. A protected
workflow writes values directly to Secrets Manager, Secret Manager, or Key Vault before the first
application release.
Database credentials are generated by OpenTofu because the database resource requires them. They remain sensitive in encrypted state and are written to a database URL secret. No root outputs the credential value.
Required production application values remain documented in apps/server/.env.example, including
BETTER_AUTH_SECRET, RESEND_API_KEY, exact FRONTEND_URL, exact SERVER_URL, pool limits,
deployment metadata, the proxy profile, and SENTRY_PROJECT_IDS when the browser error tunnel is
enabled.
Provider-specific application contracts remain in Billing, Storage, and the background-work decision.
GitHub OIDC and release workflow
.github/workflows/deploy-cloud.yml uses a protected GitHub environment and OIDC. Configure only
the matching identity inputs:
- AWS:
AWS_ROLE_ARN; - GCP:
GCP_WORKLOAD_IDENTITY_PROVIDERandGCP_SERVICE_ACCOUNT; - Azure:
AZURE_CLIENT_ID,AZURE_TENANT_ID, andAZURE_SUBSCRIPTION_ID.
Each environment also supplies protected BACKEND_HCL and TFVARS_JSON values plus public frontend
and legal URL variables. TFVARS_JSON is only the provider-specific overlay; the workflow merges it
with the locked non-secret deployment/config.json. Long-lived AWS keys, service-account JSON, and
Azure client secrets are unsupported.
The workflow rejects cloud, environment, or frontend dispatch inputs that disagree with the locked configuration. On the first release it creates only the selected container repository, builds and pushes one linux/amd64 image, provisions runtime infrastructure with public API traffic disabled, and runs the migration primitive. A successful migration admits traffic; a failed migration leaves the new service private or scaled to zero. Later releases preserve existing traffic until migration succeeds, then update the API to the exact digest. API resources ignore image drift from OpenTofu so an infrastructure apply cannot bypass the release primitive.
After readiness, the workflow builds the native frontend and runs:
API_ORIGIN=https://api.example.com \
WEB_ORIGIN=https://app.example.com \
bash scripts/verify-public-deployment.shVercel and Cloudflare Git integrations deploy their own static build after VITE_API_URL is updated
to the stable API origin.
Railway and Dokploy
Railway uses apps/server/railway.toml. Its preDeployCommand runs ./server migrate, its start
command runs ./server serve, and traffic waits for /ready.
Dokploy uses deploy/dokploy/compose.yml. A one-shot migrate service runs the same image and must
complete successfully before the API service starts. PostgreSQL remains private and persists under
/var/lib/postgresql. Verify the path with:
bash scripts/verify-dokploy-compose.shRollback and failed release recovery
Deploy additive expand/contract migrations once more than one API revision can overlap. Never automatically run database downs. On failure:
- preserve the migration execution logs and image digest;
- leave the previous API revision receiving traffic;
- fix forward unless a reviewed down is demonstrably data-safe;
- rerun the migration job with a new immutable image;
- release the API only after the job exits zero and readiness passes.
Application rollback selects the prior image digest. Frontend rollback redeploys the prior static artifact. If wire contracts changed, restore a compatible API/frontend pair.
Promote cost-first to HA
Set ha_enabled=true, review the cost and topology plan, confirm the selected region supports every
zone-redundant feature, and apply through the protected environment. Promotion changes availability;
it does not migrate providers or combine entitlements across deployments.
Troubleshooting
- Pool-capacity check failure: lower replicas or pool limits, or explicitly select a larger database tier and its documented connection capacity.
- Database SKU unavailable: set the provider-specific tier override or choose a supported region.
- API never ready: inspect database reachability, external migrations/templates, and the release digest metadata in structured logs.
- CORS failure: compare the literal browser
Originwithfrontend_origin; wildcards are not used. - Incorrect client IP: confirm only the supported ingress reaches the service and select its exact proxy profile.
- Managed certificate pending: publish every returned DNS validation record and wait for provider issuance before retrying.
- OpenTofu lock mismatch: run the pinned OpenTofu 1.12.5 release, review provider changes, regenerate the lock intentionally, and commit it.
Teardown
Teardown is destructive and never part of the deployment workflow. In a disposable environment, set
deletion_protection=false, apply that reviewed change, verify the selected state and cloud
account, then run tofu destroy. Delete state backends, backups, registries, or customer data only
with separate explicit authority. Production teardown requires a retained and restore-tested
database backup.
Proof ledger
Mocked cost-first and HA plans are repository evidence, not live-cloud acceptance. The landing page must not advertise the nine frontend/backend combinations until disposable-environment records prove custom-domain auth, CORS, CRUD ownership denial, migration failure, rollback, caching, logs, alarms, no-drift plans, and apply/destroy for every combination.