ECS
ECS (Elastic Container Service) is AWS’s managed container orchestrator: you describe a container — image, CPU, memory — and ECS runs it. With Fargate, AWS also provides the machines, so there are no servers to manage.
ECS has four primitives:
- A Cluster is the space your containers run in.
- A Task Definition is the blueprint: which image, how much CPU and memory, which IAM roles, which ports.
- A Task is a running container launched from a Task Definition. It runs until its process exits.
- A Service keeps a set number of Tasks running — restarting ones that stop, optionally routing traffic to them through a load balancer.
Alchemy models these directly:
Cluster,
Task — a task definition plus
everything needed to build and publish its image — and
Service. Each can run a plain
container image, or an Effect program that Alchemy bundles into
one.
Run a Task
Section titled “Run a Task”The simplest Task runs a pre-built registry image — no Effect
runtime in the container:
import * as AWS from "alchemy/AWS";
const migrate = yield* AWS.ECS.Task("DbMigrate", { image: "public.ecr.aws/docker/library/busybox:stable", command: ["sh", "-c", "echo done"], cpu: 256, memory: 512,});Deploying a Task automates the container supply chain:
Alchemy provisions the task and execution IAM roles, a
CloudWatch log group, and a generated ECR repository
holding the image, then registers a Fargate task definition. Each
deploy registers a new immutable revision.
The log group keeps events forever by default. Set
logging: { retention: "2 weeks" } on a Task or Service to
expire them; the duration is rounded up to the nearest value
CloudWatch Logs supports, and "forever" clears an existing policy.
Three image sources
Section titled “Three image sources”The image comes from exactly one of three sources, flat on the props:
-
image— run a pre-built registry reference, mirrored into ECR (pull → tag → push, content-addressed). -
context— build your own Dockerfile with your local Docker.dockerfileis always a path, defaulting to${context}/Dockerfile:const render = yield* AWS.ECS.Task("RenderJob", {context: "./render",dockerfile: "./render/Dockerfile.gpu",cpu: 1024,memory: 4096,}); -
main— bundle an inline Effect program into a generated image. Compose withimageto pick the environment base (defaultoven/bun:1), or with an inlinedockerfile(Dockerfile.inline) when the environment needs extra build steps.
Run an Effect program in a Task
Section titled “Run an Effect program in a Task”Pass main: import.meta.url and a constructor Effect whose impl
returns { run } — the program runs to completion when the
container starts, then the container exits. Bindings work exactly
as on Lambda, attaching environment variables and IAM policy
statements to the task:
const drainer = yield* AWS.ECS.Task( "QueueDrainer", { main: import.meta.url, image: "oven/bun:1", cpu: 256, memory: 512 }, Effect.gen(function* () { const receive = yield* AWS.SQS.ReceiveMessage(queue); return { run: Effect.gen(function* () { // runs to completion, then the container exits const batch = yield* receive({ MaxNumberOfMessages: 10 }); }), }; }),);The tagged form
(class Reindexer extends AWS.ECS.Task<Reindexer, Shape>()("Reindexer") {}
Reindexer.make(props, impl)) works exactly as it does on Lambda and Cloudflare Workers.
Invoke and schedule Tasks
Section titled “Invoke and schedule Tasks”A Task is the target of the ECS control-plane bindings. From a
Lambda function, a Service, or any other host, bind
RunTask in the Construction phase —
this grants the host ecs:RunTask plus iam:PassRole on the
task’s roles — then call it from a handler at runtime, where
the cluster and task definition ARNs are injected automatically:
const api = yield* AWS.Lambda.Function( "Api", { main: import.meta.url, functionUrl: true }, Effect.gen(function* () { // Construction: bind the launch (IAM grants happen here) const runTask = yield* AWS.ECS.RunTask(cluster, task);
return { fetch: Effect.gen(function* () { // runtime: launch a task per request const response = yield* runTask({ launchType: "FARGATE", networkConfiguration: { awsvpcConfiguration: { subnets: [subnetId] }, }, }); return yield* HttpServerResponse.json({ taskArn: response.tasks?.[0]?.taskArn, }); }), }; }),);For cron-style execution, AWS.ECS.every provisions an
EventBridge rule (plus the invoke role) that runs the task on a
schedule — plain-English durations normalize to rate(...), and
cron(...) expressions pass through as-is:
yield* AWS.ECS.every("NightlyJob", "cron(0 3 * * ? *)", { cluster, task: nightlyTask, subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], securityGroups: [jobSecurityGroup.groupId],});Keep a server running with a Service
Section titled “Keep a server running with a Service”A Service keeps desiredCount copies of a container alive.
It takes the same three image sources as Task — synthesizing
its own task definition — and loadBalancer: true provisions a
public Application Load Balancer, target group, and listener
in front of it:
const cluster = yield* AWS.ECS.Cluster("AppCluster", {});
const nginx = yield* AWS.ECS.Service("Edge", { cluster, image: "public.ecr.aws/nginx/nginx:1.27", port: 80, desiredCount: 2, loadBalancer: true,});
return { url: nginx.url }; // http://<alb-dns-name>Networking is optional to start: when vpcId/subnets are
omitted the account’s default VPC (and its per-AZ subnets) is
used, and when securityGroups is omitted with
loadBalancer: true, Alchemy provisions a security group that
admits the listener port. For a real deployment, build a
dedicated VPC with the Network
helper and pass vpcId + subnets — see
VPC & networking.
An effectful Service is the server counterpart: where a Task
impl returns { run }, a Service impl returns { fetch }:
const api = yield* AWS.ECS.Service( "Api", { cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512 }, Effect.gen(function* () { const putItem = yield* AWS.DynamoDB.PutItem(table); return { fetch: Effect.gen(function* () { return yield* HttpServerResponse.json({ ok: true }); }), }; }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),);Reuse a Task’s definition
Section titled “Reuse a Task’s definition”A Service can also deploy an existing Task’s definition
instead of owning an image — shared image, roles, and config; the
Service adds desiredCount, load balancing, and deployment
configuration:
const api = yield* AWS.ECS.Service("Api", { cluster, task: apiTask, desiredCount: 2, loadBalancer: true,});Most service configuration — desired count, task definition
revision, network config, deployment settings, load balancers —
updates in place as a rolling deployment; only truly
immutable aspects (service name, cluster, scheduling strategy,
deployment controller type, switching between launchType and
capacityProviderStrategy) 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.
Run background work
Section titled “Run background work”Containers are always-on, so an effectful container 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* () { // ... }), }; }),Use this for polling loops, queue drainers, or connections that stay open across requests.
Process scope vs request scope
Section titled “Process scope vs request scope”An ECS container is a real process, and its instance scope
reflects that: the bundled program runs under a root scope that
closes when the process shuts down gracefully, so resources
acquired in the constructor — the connection a host.run loop holds open, a
warm pool shared across requests — are genuinely released on exit.
Serverless runtimes only approximate this: workerd never closes
its instance scope at all, and Lambda gets a best-effort 500 ms
SIGTERM window; a server gets a real graceful shutdown (a hard
kill still skips finalizers, as in any process).
Each HTTP request still gets its own request Scope, released when
the response settles — the same per-event contract as every other
runtime. See
Instance scope vs request scope
for the model across all runtimes.
Bindings
Section titled “Bindings”Task and Service declare 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 containers — useful for a
function that fans work out to containers.
Where next
Section titled “Where next”- Choosing a runtime — when Lambda, EKS, or EC2 fits better than ECS.
- VPC & networking — what the
Networkhelper creates, and the primitives underneath it. - EKS — the same platform model on managed Kubernetes.
Taskreference,Servicereference,Clusterreference — every prop and attribute.