Ship the web server and API to AWS Fargate with SST.
Onekit deploys One’s Node server in an AWS Fargate container through SST 4.17.1. The container runs Node 24 with Bun 1.3.9 as the package/script runner. It supports SSR, loaders, and API routes. Native apps are built and released separately through Expo/EAS; SST hosts their shared API.
Edit infra/config.ts for the app name, AWS region, architecture, ports, optional OAuth providers, and service sizes. Defaults are us-east-1, ARM64, 0.5 vCPU / 1 GB for production, and 0.25 vCPU / 0.5 GB for other stages. Production can scale from one to two containers; other stages run one. SST supplies ONE_FORCE_PORT so the configured port also changes the server listener. Fargate and the load balancer incur ongoing charges while deployed.
The stack creates a VPC, ECS cluster, two Fargate services — Web (the app) and Sync (zero-cache, which every CRM screen reads and writes through) — each with an HTTPS load balancer, certificate, DNS records, and logs. Sync answers on sync. plus your domain (sync.app.example.com), and the web image is built with that origin as ONE_PUBLIC_ZERO_URL. It runs one task, 1 vCPU / 2 GB in production and 0.5 vCPU / 1 GB elsewhere (infraConfig.sync); its SQLite replica is rebuilt from PostgreSQL whenever a task starts. Public subnets let the services run without a NAT gateway or bastion.
The database is Amazon RDS for PostgreSQL in the same stack by default (infraConfig.database.provider: 'rds'):
db.t4g.small in production and db.t4g.micro elsewhere, 20 GB gp3, single-AZ, encrypted at rest, seven days of automated backups, and deletion protection plus a final snapshot in production.max_slot_wal_keep_size of 10 GB, so a stopped zero-cache cannot fill the disk with WAL;sslmode=verify-full) against the region’s RDS CA bundle, which the stack fetches at deploy time.onekit_zero role, which can replicate and read the CRM tables but not write them, and keeps its bookkeeping in two databases of its own, zero_cvr and zero_change.infra/config.ts). That leaves room on a t4g.micro (≈80 available) and plenty on a t4g.small. Check the sum again before raising web scaling or the pool sizes.To use a PostgreSQL you already run instead, deploy with ONEKIT_DATABASE=external (or set provider: 'external'). The stack then creates no database and reads DatabaseUrl, ZeroUpstreamDb and optionally DatabaseSslCa, below. The database needs logical replication and a direct connection for zero-cache; see Database. Either way, use a separate database and secrets for each stage.
ONEKIT_DOMAIN is required for deployment and must be a hostname in a Route 53 hosted zone in the selected AWS account, such as app.example.com. SST manages its certificate and DNS. Other DNS providers require adapting loadBalancer.domain in sst.config.ts with an SST DNS adapter.
The domain determines ONE_SERVER_URL, ONE_PUBLIC_SERVER_URL, and BETTER_AUTH_URL. Both One URLs are supplied during image build and at runtime. This makes native API calls and auth callbacks agree on the public origin without making the image build depend on the load balancer it creates. If you have these variables exported locally, unset them or set them to the deployment origin.
Install Bun 1.3.9, Node 24, Docker, and AWS credentials for the target account. Select credentials with your usual AWS profile or CI role. The examples use the explicit production stage:
bun install --frozen-lockfile
export AWS_PROFILE=your-deploy-profile
export ONEKIT_DOMAIN=app.example.com
export ONEKIT_REGION=us-east-1 # optional; match your database's region
export ONE_SERVER_URL="https://${ONEKIT_DOMAIN}"
export ONE_PUBLIC_SERVER_URL="$ONE_SERVER_URL"
export BETTER_AUTH_URL="$ONE_SERVER_URL"
bunx sst install
Set three random secrets in SST’s encrypted secret storage: the auth secret, the password of zero-cache’s database role, and the password of zero-cache’s admin inspector. RDS generates its own master password, so there is no database URL to set:
openssl rand -base64 32 | bunx sst secret set BetterAuthSecret --stage production
openssl rand -hex 32 | bunx sst secret set ZeroDbPassword --stage production
openssl rand -base64 32 | bunx sst secret set ZeroAdminPassword --stage production
With ONEKIT_DATABASE=external, set the database’s URLs instead of ZeroDbPassword. ZeroUpstreamDb is zero-cache’s own connection: direct, never pooled, as a role that can replicate (REPLICATION). For Supabase that means the direct connection with the IPv4 add-on. Migration 0017 lets a role named onekit_zero read past row-level security; any other role needs BYPASSRLS. The commands below read the values from stdin; replace the example paths with secure files:
bunx sst secret set DatabaseUrl --stage production < /secure/path/database-url.txt
bunx sst secret set ZeroUpstreamDb --stage production < /secure/path/direct-database-url.txt
If an external provider signs with its own certificate authority, also store its CA as DatabaseSslCa (set infraConfig.database.sslCa to true first, or export ONEKIT_DATABASE_SSL_CA=true):
bunx sst secret set DatabaseSslCa --stage production < /secure/path/provider-ca.crt
Secrets are passed only to the running container. They are never Docker build arguments, and .env files are excluded from the Docker context. Keep the auth secret stable between releases; rotating it invalidates existing sessions.
Review the SQL migrations and take any necessary database backup before applying schema changes. Migrations never run during an image build, at app startup, or as part of sst deploy. You run them as a step of their own.
The database is private, so migrations run inside the VPC as a one-off ECS task, Migrate. The task runs bun scripts/migrate.ts on the app’s own image, then:
onekit_zero publication;deploy:migrate starts the task and waits for it; its output goes to CloudWatch Logs.
bunx sst diff --stage production
bunx sst deploy --stage production
bunx sst shell --stage production -- bun run deploy:migrate
curl --fail https://app.example.com/api/health
curl --fail https://sync.app.example.com/keepalive
On a stage’s first deploy, the Sync service starts before its database is ready and restarts until deploy:migrate has run. The deploy itself does not wait on it, and zero-cache answers within a few minutes of the migration. On later releases, the deploy updates the task’s image and deploy:migrate applies the release’s migrations, so write migrations that the previous release can run against (add before you remove).
The health endpoint confirms the web process is running; verify sign-in and a synced screen separately. sst deploy builds and publishes the Docker image unless ONEKIT_IMAGE names one already pushed. That image must be built from this repository’s Dockerfile, which also carries drizzle/ and scripts/ for the migrate task.
Manual access: the database accepts connections only from inside the VPC. To run psql or bun run db:migrate from your machine, add bastion: true to the Vpc in sst.config.ts, deploy, and use bunx sst tunnel --stage production. The bastion is a small EC2 instance billed while it exists.
SST marks the production stage protected and uses removal: 'retain'. Other stages use removal: 'remove'. Rename the app or stage only when intentionally creating a separate stack. To deploy a staging environment, repeat secret setup with --stage staging, use a separate database, set ONEKIT_DOMAIN=staging.example.com and update the three URL exports above, then deploy with --stage staging.
Email/password authentication needs no OAuth provider credentials. To enable Google, set infraConfig.oauth.google to true, create an OAuth application with callback https://app.example.com/api/auth/callback/google, then set these SST secrets using secure files or your secret manager’s stdout:
bunx sst secret set GoogleClientId --stage production < /secure/path/google-client-id.txt
bunx sst secret set GoogleClientSecret --stage production < /secure/path/google-client-secret.txt
To enable Apple, set infraConfig.oauth.apple to true, configure Sign in with Apple with callback https://app.example.com/api/auth/callback/apple, and set:
bunx sst secret set AppleClientId --stage production < /secure/path/apple-client-id.txt
bunx sst secret set AppleClientSecret --stage production < /secure/path/apple-client-secret.txt
Apple’s client secret is a signed JWT with an expiration, not the .p8 private key. Renew it before expiry. Both credentials in a provider pair must be present. The server receives GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET and APPLE_CLIENT_ID / APPLE_CLIENT_SECRET; redeploy after enabling a provider or updating a secret. For native development, retain the onekit URL scheme and follow the auth setup documentation.
Set infraConfig.email.from to an identity verified in SES for the deployment region, for example Onekit <no-reply@app.example.com>. The service then receives EMAIL_PROVIDER=ses, EMAIL_FROM, and EMAIL_REGION, and its task role is granted ses:SendEmail. Set infraConfig.email.requireVerification to true to refuse sign-in until addresses are confirmed. New AWS accounts start in the SES sandbox, which only delivers to verified recipients; request production access before launch. See Email.
Set infraConfig.billing to true and store the live Stripe keys as secrets:
bunx sst secret set StripeSecretKey --stage production < /secure/path/stripe-secret-key.txt
bunx sst secret set StripeWebhookSecret --stage production < /secure/path/stripe-webhook-secret.txt
Register https://app.example.com/api/auth/stripe/webhook in the Stripe dashboard for the subscription events listed in Billing, and use that endpoint’s signing secret. Redeploy after changing either secret.
Set infraConfig.demoSandbox to true (or export ONEKIT_DEMO_SANDBOX=true for one deployment) to let anyone start a throwaway guest account in a seeded sandbox workspace at /demo. It creates accounts for whoever asks, so leave it off unless the deployment is a public demo.
Every resource the stack creates is tagged project with infraConfig.name. In an account you share with other projects, filter Cost Explorer and the console by that tag, and remove the whole stage with bunx sst remove --stage <stage> when you’re done.
Build and serve locally before using AWS:
ONE_SERVER_URL=http://localhost:3000 ONE_PUBLIC_SERVER_URL=http://localhost:3000 bun run build
ONE_SERVER_URL=http://localhost:3000 ONE_PUBLIC_SERVER_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000 bun run start
The server listens on port 3000. Provide your local DATABASE_URL and BETTER_AUTH_SECRET when exercising auth. Plain bun run dev is the local development path. sst dev can create AWS infrastructure, so it is not needed to develop this starter locally.
One writes web assets to dist/client, SSR output to dist/server, and API handlers to dist/api. The runtime image includes dist, package metadata, and installed dependencies. It starts bun run start, which invokes one serve --host 0.0.0.0 --port 3000. No app source or environment files are copied into the runtime image.
References: One deployment, One production server, SST Service, SST v4.