Skip to content

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.

The simplest Task runs a pre-built registry image — no Effect runtime in the container:

alchemy.run.ts
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.

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. dockerfile is 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 with image to pick the environment base (default oven/bun:1), or with an inline dockerfile (Dockerfile.inline) when the environment needs extra build steps.

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.

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],
});

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)),
);

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.

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.

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.

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.