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 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 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.

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

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 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.

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.