Summary
ts-cloud handles static frontends (S3 + CloudFront) and trivial serverless functions well, but there is no clear, documented, end-to-end path for deploying a full backend application — an HTTP API that also needs a relational database, queues / background jobs, transactional email, and a cache — and attaching it to an existing static frontend.
This issue captures the compute-model decision and the concrete code gaps found while scoping such a deploy, so the result is a repeatable pattern rather than a one-off.
Target architecture (generic)
<domain> (DNS → CloudFront)
│
┌──────────────────────┼──────────────────────┐
/* /api/* (or api.<domain>) /docs/* (later)
(default) (backend)
│ │
S3 (static, untouched) ALB → ECS Fargate service (the app, one long-running container)
│
┌──────────────────────┼───────────────┬───────────────┐
RDS / Aurora ElastiCache (Redis) SQS (+ worker) SES (mail)
- Frontend stays on S3 + CloudFront, untouched.
- Backend runs as a long-running container on ECS Fargate.
- All stateful concerns move into managed services (RDS, ElastiCache, SQS, SES).
Compute model: why ECS Fargate over Lambda for a full backend
Two paths were evaluated.
Lambda-native
API Lambda + SQS-triggered worker Lambdas + EventBridge cron + RDS Proxy (or DynamoDB).
- ✅ Scales to zero; cheapest for spiky / low traffic.
- ❌ DB connection pooling — Lambda scales horizontally and each concurrent instance opens its own DB connection, exhausting
max_connections. Needs RDS Proxy / Aurora Data API / DynamoDB to mitigate.
- ❌ 15-minute execution cap — no long-running work.
- ❌ No in-process workers — queues/cron must be re-architected into separate event-driven functions.
- ❌ Cold starts on a large app bundle.
ECS Fargate (recommended)
Runs the whole app as a long-running container: one process, in-process queue worker, persistent DB connection pool, no cold starts, no 15-min cap, websocket-capable. Matches how a Bun-based server (e.g. a Stacks app) is designed to run — 1:1, no decomposition tax.
- Cost tradeoff: an always-on baseline (small Fargate task + ALB + small RDS ≈ a few tens of $/mo) even at zero traffic.
Recommendation: ECS on the Fargate launch type for the API; reserve Lambda for genuinely event-driven / async pieces. Revisit ECS-on-EC2 only at steady high scale where bin-packing offsets the ops overhead.
What ts-cloud already supports
fullstack-app preset — S3 + CloudFront (frontend) + ECS Fargate (API) + RDS + Redis, i.e. exactly this shape.
- AWS clients present:
ecs, elbv2 (ALB), ecr, rds, elasticache, secrets-manager, sqs, ses, eventbridge, scheduler.
- ECS client methods:
createCluster, createService, registerTaskDefinition, updateService.
So most building blocks exist; the work is an orchestrated, documented flow plus closing the gaps below.
Proposed end-to-end flow
- Containerize — Dockerfile on the
oven/bun base image, bun start.
- Build + push image to ECR — ts-cloud should orchestrate build + registry auth + push (no AWS CLI dependency).
- Provision data tier — RDS (Postgres) + ElastiCache (Redis); credentials in Secrets Manager.
- ECS cluster + Fargate service — task definition (CPU/mem, env, secret refs), desired count, autoscaling.
- ALB + target group + health check in front of the service.
- Wire CloudFront — add a backend origin + cache behavior (
/api/*) to the existing distribution, or a dedicated subdomain → ALB.
- Security groups — Fargate tasks → RDS/Redis; ALB → tasks.
- Async tier (optional) — SQS + worker (in-process on Fargate, or a Bun Lambda); EventBridge for scheduled jobs.
Gaps found while scoping
1. CloudFront: cannot add an origin / behavior to an existing distribution
updateDistribution() (packages/ts-cloud/src/aws/cloudfront.ts) only mutates aliases / certificate / comment. getDistributionConfig() and buildDistributionConfigXml() can already read and serialize Origins and CacheBehaviors, but no high-level method splices a new origin + cache behavior into a live distribution. This blocks the single-distribution /api/* design (attaching a backend origin to an existing static-site distribution).
Needed: extend updateDistribution() (or add a dedicated method) to round-trip the full config — GET config → add origin + behavior → PUT with the If-Match ETag.
2. API Gateway / custom-domain builder hardcodes a Route53 record
addApiCustomDomain() (packages/core/src/cloudformation/builders/api-gateway.ts, ~line 389) unconditionally emits an AWS::Route53::RecordSet. For a domain on an external DNS provider (no Route53 hosted zone) this resource fails at CREATE and rolls back the whole stack. The static-site flow already has an external-DNS path (static-site-external-dns.ts plus the DNS providers under src/dns/ — Porkbun / GoDaddy / Cloudflare); there is no equivalent for API / ALB custom domains.
Needed: make the Route53 record conditional (skip for external DNS) and provide an "API/ALB + external DNS" orchestrator that requests the ACM cert and creates the alias/CNAME via the configured DNS provider.
3. Lambda layer support — done in scoping
createFunction() and updateFunctionConfiguration() did not expose Layers. Added Layers?: string[] to both (packages/ts-cloud/src/aws/lambda.ts); both already forward params to the Lambda API verbatim. Required for any custom runtime (e.g. a Bun runtime layer). Also added bun: 'provided.al2023' to the runtime map in packages/core/src/modules/compute.ts.
4. Bun-on-Lambda runtime — productize for the async tier
A working PoC exists: a custom-runtime layer (provided.al2023) bundling the Bun binary + a Runtime API loop that adapts Lambda events ↔ a Bun.serve-style fetch(request) handler (API Gateway / Function URL payload format 2.0).
Findings worth recording:
- Bun aarch64 binary ≈ 90 MB → well under the 250 MB unzipped limit. Size is not a concern.
- Function code is just the handler source (Bun runs TS directly) — a few KB.
- Cold start is read from the first invoke's CloudWatch
Init Duration.
Could be productized as a ts-cloud helper that builds + publishes the Bun runtime layer and attaches it to a function — useful for SQS / EventBridge-triggered workers even when the primary API runs on Fargate.
Open questions
- Database: RDS + connection pooling vs Aurora Serverless v2 vs DynamoDB — pick a sensible default and document the connection story for both Fargate and Lambda consumers.
- Image build: does ts-cloud build the Docker image itself, and what does it assume about a local Docker/buildx daemon? Document the ECR auth + push flow.
- CloudFront → ALB origin: a custom HTTPS origin needs an ALB certificate + origin protocol policy — document.
- Zero-downtime deploys / rolling updates on the Fargate service.
- Secrets injection — Secrets Manager → task-definition
secrets.
Acceptance criteria
- A documented, repeatable flow (preset config + deploy command) that stands up: ECR image → RDS + Redis → Fargate service → ALB → CloudFront origin, fronting an existing static distribution without disturbing it.
- External-DNS support for the API / ALB custom domain (no Route53 dependency).
- Ability to add a backend origin + cache behavior to an existing CloudFront distribution.
- A short guidance doc: the compute-model decision (Lambda vs Fargate), DB connection handling, and a cost baseline.
Out of scope
- Specific application code / business logic.
- DNS provider migration.
- Multi-region.
Summary
ts-cloud handles static frontends (S3 + CloudFront) and trivial serverless functions well, but there is no clear, documented, end-to-end path for deploying a full backend application — an HTTP API that also needs a relational database, queues / background jobs, transactional email, and a cache — and attaching it to an existing static frontend.
This issue captures the compute-model decision and the concrete code gaps found while scoping such a deploy, so the result is a repeatable pattern rather than a one-off.
Target architecture (generic)
Compute model: why ECS Fargate over Lambda for a full backend
Two paths were evaluated.
Lambda-native
API Lambda + SQS-triggered worker Lambdas + EventBridge cron + RDS Proxy (or DynamoDB).
max_connections. Needs RDS Proxy / Aurora Data API / DynamoDB to mitigate.ECS Fargate (recommended)
Runs the whole app as a long-running container: one process, in-process queue worker, persistent DB connection pool, no cold starts, no 15-min cap, websocket-capable. Matches how a Bun-based server (e.g. a Stacks app) is designed to run — 1:1, no decomposition tax.
Recommendation: ECS on the Fargate launch type for the API; reserve Lambda for genuinely event-driven / async pieces. Revisit ECS-on-EC2 only at steady high scale where bin-packing offsets the ops overhead.
What ts-cloud already supports
fullstack-apppreset —S3 + CloudFront (frontend) + ECS Fargate (API) + RDS + Redis, i.e. exactly this shape.ecs,elbv2(ALB),ecr,rds,elasticache,secrets-manager,sqs,ses,eventbridge,scheduler.createCluster,createService,registerTaskDefinition,updateService.So most building blocks exist; the work is an orchestrated, documented flow plus closing the gaps below.
Proposed end-to-end flow
oven/bunbase image,bun start./api/*) to the existing distribution, or a dedicated subdomain → ALB.Gaps found while scoping
1. CloudFront: cannot add an origin / behavior to an existing distribution
updateDistribution()(packages/ts-cloud/src/aws/cloudfront.ts) only mutates aliases / certificate / comment.getDistributionConfig()andbuildDistributionConfigXml()can already read and serializeOriginsandCacheBehaviors, but no high-level method splices a new origin + cache behavior into a live distribution. This blocks the single-distribution/api/*design (attaching a backend origin to an existing static-site distribution).Needed: extend
updateDistribution()(or add a dedicated method) to round-trip the full config — GET config → add origin + behavior → PUT with theIf-MatchETag.2. API Gateway / custom-domain builder hardcodes a Route53 record
addApiCustomDomain()(packages/core/src/cloudformation/builders/api-gateway.ts, ~line 389) unconditionally emits anAWS::Route53::RecordSet. For a domain on an external DNS provider (no Route53 hosted zone) this resource fails at CREATE and rolls back the whole stack. The static-site flow already has an external-DNS path (static-site-external-dns.tsplus the DNS providers undersrc/dns/— Porkbun / GoDaddy / Cloudflare); there is no equivalent for API / ALB custom domains.Needed: make the Route53 record conditional (skip for external DNS) and provide an "API/ALB + external DNS" orchestrator that requests the ACM cert and creates the alias/CNAME via the configured DNS provider.
3. Lambda layer support — done in scoping
createFunction()andupdateFunctionConfiguration()did not exposeLayers. AddedLayers?: string[]to both (packages/ts-cloud/src/aws/lambda.ts); both already forward params to the Lambda API verbatim. Required for any custom runtime (e.g. a Bun runtime layer). Also addedbun: 'provided.al2023'to the runtime map inpackages/core/src/modules/compute.ts.4. Bun-on-Lambda runtime — productize for the async tier
A working PoC exists: a custom-runtime layer (
provided.al2023) bundling the Bun binary + a Runtime API loop that adapts Lambda events ↔ aBun.serve-stylefetch(request)handler (API Gateway / Function URL payload format 2.0).Findings worth recording:
Init Duration.Could be productized as a ts-cloud helper that builds + publishes the Bun runtime layer and attaches it to a function — useful for SQS / EventBridge-triggered workers even when the primary API runs on Fargate.
Open questions
secrets.Acceptance criteria
Out of scope