Skip to content

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

Description

@glennmichael123

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 presetS3 + 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions