Skip to content

ECS

ECS runs containers. You describe a container, its image, CPU and memory, and ECS keeps it running. With Fargate, AWS also provides the machines underneath, so there are no servers to patch or scale.

A container is a whole process, so it fits work that doesn’t fit an invocation: a server that holds WebSockets or database connections open, a worker that loops forever, a job that runs for hours, a program that already ships as an image.

Clusters, task definitions, tasks and services

Section titled “Clusters, task definitions, tasks and services”

A task definition is a blueprint: which image, how much CPU and memory, which ports, which IAM role. A task is a running container started from it, and it runs until its process exits. A service keeps a number of tasks running, replaces any that stop, and optionally puts a load balancer in front of them. All of them run in a cluster.

ClusterService · desiredCount: 2runstask definitionimage · cpu · roletaskrunningtaskrunningload balancerHTTP
A Service in a cluster keeps tasks running from a task definition. The load balancer spreads requests across them.

In Alchemy, AWS.ECS.Task is a task definition together with everything that builds its image, and AWS.ECS.Service keeps tasks running, with or without a Task of its own.

Declare a cluster, then a Service that runs an image. loadBalancer: true puts a public Application Load Balancer in front of it:

alchemy.run.ts
const cluster = yield* AWS.ECS.Cluster("AppCluster", {});
const web = yield* AWS.ECS.Service("Web", {
cluster,
image: "public.ecr.aws/nginx/nginx:1.27",
port: 80,
desiredCount: 2,
loadBalancer: true,
});
return { url: web.url };

Deploy copies the image into a repository in your account, creates the task’s roles and log group, registers the task definition, and starts two tasks behind the load balancer. Without vpcId and subnets, the tasks run in your account’s default VPC. Give them a network of your own for production. See VPC & networking.

Give the Service main and an Effect, and Alchemy builds an image from your program. The program returns a fetch handler, and binds what it uses exactly as a Lambda Function does:

alchemy.run.ts
const api = yield* AWS.ECS.Service(
"Api",
{ cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512, loadBalancer: true },
Effect.gen(function* () {
const putItem = yield* AWS.DynamoDB.PutItem(Items);
return {
fetch: Effect.succeed(HttpServerResponse.json({ ok: true })),
};
}).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
);

Each binding adds its statement to the task role, the role the container runs as, and its environment variables to the container.

The image comes from one of three places: image, an image from any registry, copied into your account; context, your own Dockerfile, built with your local Docker; or main, your Effect program, bundled into a base image, oven/bun:1 unless you set image. Images are built for the architecture the task runs on. Set runtimePlatform: { cpuArchitecture: "ARM64" } to run on Graviton, which costs less per vCPU.

A Task on its own runs until its process exits. Give it a run Effect instead of fetch:

alchemy.run.ts
const reindex = yield* AWS.ECS.Task(
"Reindex",
{ main: import.meta.url, cpu: 1024, memory: 2048 },
Effect.gen(function* () {
const scan = yield* AWS.DynamoDB.Scan(Items);
return {
run: Effect.gen(function* () {
const page = yield* scan({});
yield* Effect.log(`reindexing ${page.Count} items`);
}),
};
}).pipe(Effect.provide(AWS.DynamoDB.ScanHttp)),
);

Start it from another runtime by binding ECS.RunTask, which grants permission to run that task and pass its roles:

src/api.ts
const runTask = yield* AWS.ECS.RunTask(cluster, reindex);
yield* runTask({
launchType: "FARGATE",
networkConfiguration: { awsvpcConfiguration: { subnets: network.privateSubnetIds } },
});

ECS.every runs a Task on a rate or a cron expression, with the role the schedule needs to start it:

alchemy.run.ts
yield* AWS.ECS.every("NightlyReindex", "cron(0 3 * * ? *)", {
cluster,
task: reindex,
subnets: network.privateSubnetIds,
securityGroups: [jobs.groupId],
});

See EventBridge & Scheduler.

A container is always running, so it can do more than answer requests. Register a loop with ServerHost, and it runs alongside the fetch handler for the life of the container:

alchemy.run.ts
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.succeed(HttpServerResponse.text("ok")) };
});

Use it for polling, draining a queue, or a connection that stays open across requests.

A container is a real process. What Construction acquires, a pool or a connection, lives until the process shuts down, and its finalizers run when ECS stops the task gracefully. Each request still gets its own Scope, closed when the response is sent.

This is the main difference from Lambda, where an environment can be frozen between invocations and its finalizers only get a moment at shutdown. See Instance scope vs request scope.

Each deploy registers a new revision of the task definition. The Service then replaces its tasks with ones from the new revision, a few at a time, so it keeps serving. Changes to the count, the network, the load balancer and deployment settings apply the same way.

A few changes can’t be applied to a running service, like its name or its cluster. Those replace the Service.

Tasks cost the same whether busy or idle. For work that can be interrupted, run on Fargate Spot, AWS’s spare capacity at a steep discount, with a share on regular Fargate so the service never disappears entirely:

alchemy.run.ts
capacityProviderStrategy: [
{ capacityProvider: "FARGATE_SPOT", weight: 3 },
{ capacityProvider: "FARGATE", weight: 1, base: 1 },
],

A Spot task gets two minutes’ notice before it’s stopped, so keep its work resumable.