ReviewOS

stacks/ts-cloud

Deploy a full-stack containerized backend (API + DB + queues + mail + cache) on ECS Fargate, fronted by an existing CloudFront/S3 site

#118
Closed glennmichael123 opened this 2 months ago ·
2 months ago

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.

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

  1. Containerize — Dockerfile on the oven/bun base image, bun start.
  2. Build + push image to ECR — ts-cloud should orchestrate build + registry auth + push (no AWS CLI dependency).
  3. Provision data tier — RDS (Postgres) + ElastiCache (Redis); credentials in Secrets Manager.
  4. ECS cluster + Fargate service — task definition (CPU/mem, env, secret refs), desired count, autoscaling.
  5. ALB + target group + health check in front of the service.
  6. Wire CloudFront — add a backend origin + cache behavior (/api/*) to the existing distribution, or a dedicated subdomain → ALB.
  7. Security groups — Fargate tasks → RDS/Redis; ALB → tasks.
  8. 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.
8 days ago

Roadmap relationship: tracked by #134 under master roadmap #132.

8 days ago

Implemented end to end on main:

  • ccaa431 — ETag-protected existing-distribution origin/behavior patch and exact rollback.
  • 42ebf96 — external API DNS ownership and preserved CloudFormation resource outputs.
  • 5bda899 — private-subnet Fargate tasks, ALB/target/listeners, health checks, autoscaling, secret injection, and circuit-breaker rollback.
  • dcf40b2 — backend-only preset for an existing static frontend with RDS, Redis, SQS/DLQ, and SES task permissions.
  • a6cc294 — CloudFront origin header enforcement: matching requests forward; direct ALB access receives 403.
  • c72598d — guarded full-stack planner/executor: account+alias preflight, stack create/update, external DNS, origin health gate, CloudFront patch, and exact rollback metadata.
  • c24a2d4 — content-hashed, scan-on-push, immutable ECR artifact publishing and digest resolution.
  • 2c2209a — container:artifact, deploy:fullstack, and deploy:fullstack:cost CLI workflows.
  • ae125b2 — data-safe lifecycle policies: PostgreSQL/Redis snapshots, retained DB secret and queues, clean ALB rollback behavior.
  • 349e368 — complete application contract, architecture, external-DNS, secrets, deployment, rollback, retention, and cost runbook.

The generated default shape is one immutable image → ECS Fargate/ALB → existing CloudFront /api/*, with PostgreSQL, TLS Redis, retained SQS/DLQ, Secrets Manager injection, and SES send permissions. The existing S3 bucket, default behavior, viewer certificate, aliases, and static content are outside the backend stack.

Verification:

  • 2,681 pass, 0 fail, 2 skip, 2 todo
  • 7,421 assertions across 381 files
  • root TypeScript check clean
  • CLI bundle: 274 modules
  • documentation: 40 pages
  • focused full-stack/container suites cover immutable-artifact rejection, read-only planning, external DNS ordering, health-before-routing, ALB origin restriction, output preservation, stateful retention, and cost component disclosure

Current July 2026 us-east-1 baseline emitted by the estimator is $93.33/month before usage/storage/transfer/public-IPv4/tax: Fargate $9.01, ALB $16.43, NAT $32.85, PostgreSQL $11.68, and two Redis nodes $23.36.

The separate real-world paweldregan.com Lambda acceptance run remains tracked by #117; its local stacks AWS credential currently returns InvalidClientTokenId, so no live production mutation was attempted from an unverified identity.

Sign in to comment on this issue.