ECS
ECS is the runtime for workloads that don’t fit Lambda’s
request/response window: WebSocket servers, workers that hold
connections open, jobs longer than 15 minutes, anything that
should be always-on. Alchemy models it with three resources —
Cluster,
Task, and
Service — driven by the same
pattern as Lambda: bundle an Effect program,
deploy it as a resource, keep composing around it.
The trade against Lambda: you pay for running tasks whether or not they’re serving traffic, containers take tens of seconds to launch instead of milliseconds, and you bring a VPC. See Choosing a runtime for the full comparison.
Define a Task
Section titled “Define a Task”A Task looks just like a Lambda Function — a class wrapping
an Effect program, with fetch handling HTTP. The difference is
what happens at deploy time: instead of a zip, the program is
baked into a Docker image.
import * as AWS from "alchemy/AWS";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default class Server extends AWS.ECS.Task<Server>()( "Server", { main: import.meta.url, cpu: 256, memory: 512, port: 3000, }, Effect.gen(function* () { return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const url = new URL(request.url, "http://task"); if (url.pathname === "/health") { return yield* HttpServerResponse.json({ ok: true }); } return HttpServerResponse.text("hello from ECS"); }), }; }),) {}Deploying a Task automates the whole container supply chain:
Alchemy bundles the program, generates a Dockerfile (base image
public.ecr.aws/docker/library/bun:1 unless you override
docker.base or supply a full docker.dockerfile), builds the
image with your local Docker, pushes it to a
generated ECR repository, provisions the task and
execution IAM roles and a CloudWatch log group, and
registers a Fargate task definition. Each deploy registers a new
immutable revision. The main image is always built this way;
only sidecars take user-supplied image URIs, which you can
produce as explicit resources with
Docker.Image.
Run background work
Section titled “Run background work”Containers are always-on, so a Task can do more than answer
requests. Yield ServerHost and register long-running loops
with host.run — they execute alongside the HTTP handler for
the life of the container:
import * as AWS from "alchemy/AWS";import { ServerHost } from "alchemy/Server";import * as Effect from "effect/Effect";import * as Schedule from "effect/Schedule";
Effect.gen(function* () { const host = yield* ServerHost;
yield* host.run( Effect.log("heartbeat").pipe( Effect.repeat(Schedule.spaced("30 seconds")), Effect.asVoid, ), );
return { fetch: Effect.gen(function* () { // ... }), }; }),This is the shape Lambda can’t give you: a polling loop, a queue drainer, or an open connection that outlives any single request.
Give it a network
Section titled “Give it a network”ECS services run with awsvpc networking, so you need a VPC
with at least two subnets (an ALB spans two Availability Zones)
and a security group that admits the listener and container
ports. The Network helper builds
the standard layout in one call — see
VPC & networking for what’s inside it:
const network = yield* AWS.EC2.Network("AppNetwork", { cidrBlock: "10.42.0.0/16",});
const securityGroup = yield* AWS.EC2.SecurityGroup("AppSg", { vpcId: network.vpcId, description: "ALB + container traffic", ingress: [ { ipProtocol: "tcp", fromPort: 80, toPort: 80, cidrIpv4: "0.0.0.0/0", description: "ALB ingress", }, { ipProtocol: "tcp", fromPort: 3000, toPort: 3000, cidrIpv4: "0.0.0.0/0", description: "container traffic", }, ], egress: [ { ipProtocol: "-1", cidrIpv4: "0.0.0.0/0", description: "all outbound", }, ],});Keep it running with a Service
Section titled “Keep it running with a Service”A Cluster owns services; a Service keeps your task
definition running at desiredCount replicas. Set
public: true and Alchemy provisions a public Application
Load Balancer, listener, and target group in front of it:
// alchemy.run.ts (continued)const cluster = yield* AWS.ECS.Cluster("AppCluster", {});const server = yield* Server;
const service = yield* AWS.ECS.Service("AppService", { cluster, task: { taskDefinitionArn: server.taskDefinitionArn, containerName: server.containerName, port: server.port, }, desiredCount: 1, vpcId: network.vpcId, subnets: network.publicSubnetIds, securityGroups: [securityGroup.groupId], assignPublicIp: true, public: true, healthCheckPath: "/health",});
return { url: service.url };service.url is the ALB’s public endpoint. Most service
configuration — desired count, task definition revision,
network config, deployment settings — updates in place as a
rolling deployment; only truly immutable aspects (service name,
cluster, scheduling strategy) replace the service.
For cost-sensitive workers, swap launchType (default
"FARGATE") for a capacityProviderStrategy mixing
FARGATE_SPOT and FARGATE — see the
Service reference for the
placement, deployment, and Service Connect knobs.
Bindings
Section titled “Bindings”Task declares the same binding contract as a Lambda
Function: bindings attach environment variables and IAM
policy statements, which Alchemy folds into the container
environment and the task role. The ECS control-plane bindings
(RunTask, StopTask, ListTasks, DescribeTasks) work from
Lambda functions and from other tasks — useful for a function
that fans work out to containers. Coverage of the wider
building-block bindings on ECS hosts is still expanding; Lambda
remains the most fully bound runtime.
Where next
Section titled “Where next”- Choosing a runtime — when Lambda or EC2 fits better than ECS.
- VPC & networking — what the
Networkhelper creates, and the primitives underneath it. - Lambda — the default runtime and the fully documented binding surface.
Taskreference,Servicereference,Clusterreference — every prop and attribute.