SsuperslateDocs

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.

BackendNative frontendExternal frontendAPIPostgreSQL
AWSS3 + CloudFrontVercel SPA or Cloudflare Workers Static AssetsECS Fargate + HTTPS ALBRDS PostgreSQL 18
GCPGCS + Cloud CDNVercel SPA or Cloudflare Workers Static AssetsCloud RunCloud SQL PostgreSQL 18
AzureStatic Web AppsVercel SPA or Cloudflare Workers Static AssetsContainer AppsFlexible 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.

  • native creates the cloud-native static host.
  • vercel accepts an existing exact production origin and creates no Vercel resources.
  • cloudflare accepts 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:configure

Restore removed paths from a fresh licensed release before changing a locked provider.

Delivery files

  • apps/server/Dockerfile builds the shared production image.
  • apps/server/railway.toml defines the Railway release and service commands.
  • deploy/dokploy/compose.yml defines the one-shot migration and API services.
  • apps/web contains the static SPA and its native, Vercel, and Cloudflare adapters.
  • .github/workflows/deploy-cloud.yml implements the cloud OIDC release sequence.

Runtime contract

The compiled image exposes two deployment commands:

./server migrate
./server serve

migrate 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 connections

Every 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.md

Each 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:infra

This 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.md creates a private, encrypted, versioned S3 bucket using native use_lockfile locking.
  • infra/bootstrap/gcp/README.md creates a uniform-access, versioned GCS bucket dedicated to one environment. GCS provides native state locking.
  • infra/bootstrap/azure/README.md creates 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_origin and frontend_origin;
  • container_repository;
  • api_service_id, migration_job_id, and the provider-specific release_target;
  • database_secret_id, never its value;
  • dns_validation_records;
  • native_frontend_target and native_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.tfvars

Cost-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.tfvars

Cost-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.tfvars

Cost-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/privacy

Native

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_domain supplied to OpenTofu;
  • VITE_API_URL: the api_origin output.

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_PROVIDER and GCP_SERVICE_ACCOUNT;
  • Azure: AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_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.sh

Vercel 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.sh

Rollback and failed release recovery

Deploy additive expand/contract migrations once more than one API revision can overlap. Never automatically run database downs. On failure:

  1. preserve the migration execution logs and image digest;
  2. leave the previous API revision receiving traffic;
  3. fix forward unless a reviewed down is demonstrably data-safe;
  4. rerun the migration job with a new immutable image;
  5. 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 Origin with frontend_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.

On this page