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.
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 an init 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 init 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* () { // init: 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 at init — 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.