1---
2title: ECS
3description: Run containers on AWS with ECS and Fargate — Task definitions that run to completion, Services that keep containers running behind a load balancer, with images bundled from an Effect program, built from your Dockerfile, or mirrored from a registry.
4---
5
6**ECS** (Elastic Container Service) is AWS's managed container
7orchestrator: you describe a container — image, CPU, memory — and
8ECS runs it. With **Fargate**, AWS also provides the machines, so
9there are no servers to manage.
10
11ECS has four primitives:
12
13- A **Cluster** is the space your containers run in.
14- A **Task Definition** is the blueprint: which image, how much
15 CPU and memory, which IAM roles, which ports.
16- A **Task** is a running container launched from a Task
17 Definition. It runs until its process exits.
18- A **Service** keeps a set number of Tasks running — restarting
19 ones that stop, optionally routing traffic to them through a
20 load balancer.
21
22Alchemy models these directly:
23[`Cluster`](/providers/aws/ecs/cluster),
24[`Task`](/providers/aws/ecs/task) — a task definition plus
25everything needed to build and publish its image — and
26[`Service`](/providers/aws/ecs/service). Each can run a plain
27container image, or an Effect program that Alchemy bundles into
28one.
29
30## Run a Task
31
32The simplest `Task` runs a pre-built registry image — no Effect
33runtime in the container:
34
35```typescript
36// alchemy.run.ts
37import * as AWS from "alchemy/AWS";
38
39const migrate = yield* AWS.ECS.Task("DbMigrate", {
40 image: "public.ecr.aws/docker/library/busybox:stable",
41 command: ["sh", "-c", "echo done"],
42 cpu: 256,
43 memory: 512,
44});
45```
46
47Deploying a `Task` automates the container supply chain:
48Alchemy provisions the **task and execution IAM roles**, a
49**CloudWatch log group**, and a **generated ECR repository**
50holding the image, then registers a Fargate task definition. Each
51deploy registers a new immutable revision.
52
53## Three image sources
54
55The image comes from exactly one of three sources, flat on the
56props:
57
58- `image` — run a pre-built registry reference, mirrored into ECR
59 (pull → tag → push, content-addressed).
60- `context` — build your own Dockerfile with your
61 [local Docker](/docker/setup). `dockerfile` is always a **path**,
62 defaulting to `${context}/Dockerfile`:
63
64 ```typescript
65 const render = yield* AWS.ECS.Task("RenderJob", {
66 context: "./render",
67 dockerfile: "./render/Dockerfile.gpu",
68 cpu: 1024,
69 memory: 4096,
70 });
71 ```
72
73- `main` — bundle an inline Effect program into a generated image.
74 Compose with `image` to pick the environment base (default
75 `oven/bun:1`), or with an inline `dockerfile`
76 (`Dockerfile.inline`) when the environment needs extra build
77 steps.
78
79:::tip
80`runtimePlatform.cpuArchitecture` picks the architecture the task
81runs on in Fargate (default `X86_64`). Alchemy builds or mirrors
82the image for whatever the task definition declares, regardless of
83the machine you build on. Declaring `"ARM64"` runs the task on
84Graviton — cheaper per vCPU, and image builds happen to be native
85(no emulation) on Apple Silicon machines.
86:::
87
88## Run an Effect program in a Task
89
90Pass `main: import.meta.url` and a constructor Effect whose impl
91returns `{ run }` — the program runs to completion when the
92container starts, then the container exits. Bindings work exactly
93as on Lambda, attaching environment variables and IAM policy
94statements to the task:
95
96```typescript
97const drainer = yield* AWS.ECS.Task(
98 "QueueDrainer",
99 { main: import.meta.url, image: "oven/bun:1", cpu: 256, memory: 512 },
100 Effect.gen(function* () {
101 const receive = yield* AWS.SQS.ReceiveMessage(queue);
102 return {
103 run: Effect.gen(function* () {
104 // runs to completion, then the container exits
105 const batch = yield* receive({ MaxNumberOfMessages: 10 });
106 }),
107 };
108 }),
109);
110```
111
112The tagged form
113(`class Reindexer extends AWS.ECS.Task<Reindexer, Shape>()("Reindexer") {}`
114+ `Reindexer.make(props, impl)`) works exactly as it does on
115Lambda and Cloudflare Workers.
116
117## Invoke and schedule Tasks
118
119A `Task` is the target of the ECS control-plane bindings. From a
120Lambda function, a Service, or any other host, bind
121[`RunTask`](/providers/aws/ecs/runtask) in the **Construction phase** —
122this grants the host `ecs:RunTask` plus `iam:PassRole` on the
123task's roles — then call it from a handler at **runtime**, where
124the cluster and task definition ARNs are injected automatically:
125
126```typescript
127const api = yield* AWS.Lambda.Function(
128 "Api",
129 { main: import.meta.url, functionUrl: true },
130 Effect.gen(function* () {
131 // Construction: bind the launch (IAM grants happen here)
132 const runTask = yield* AWS.ECS.RunTask(cluster, task);
133
134 return {
135 fetch: Effect.gen(function* () {
136 // runtime: launch a task per request
137 const response = yield* runTask({
138 launchType: "FARGATE",
139 networkConfiguration: {
140 awsvpcConfiguration: { subnets: [subnetId] },
141 },
142 });
143 return yield* HttpServerResponse.json({
144 taskArn: response.tasks?.[0]?.taskArn,
145 });
146 }),
147 };
148 }),
149);
150```
151
152For cron-style execution, `AWS.ECS.every` provisions an
153EventBridge rule (plus the invoke role) that runs the task on a
154schedule — plain-English durations normalize to `rate(...)`, and
155`cron(...)` expressions pass through as-is:
156
157```typescript
158yield* AWS.ECS.every("NightlyJob", "cron(0 3 * * ? *)", {
159 cluster,
160 task: nightlyTask,
161 subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId],
162 securityGroups: [jobSecurityGroup.groupId],
163});
164```
165
166## Keep a server running with a Service
167
168A `Service` keeps `desiredCount` copies of a container alive.
169It takes the same three image sources as `Task` — synthesizing
170its own task definition — and `loadBalancer: true` provisions a
171public **Application Load Balancer**, target group, and listener
172in front of it:
173
174```typescript
175const cluster = yield* AWS.ECS.Cluster("AppCluster", {});
176
177const nginx = yield* AWS.ECS.Service("Edge", {
178 cluster,
179 image: "public.ecr.aws/nginx/nginx:1.27",
180 port: 80,
181 desiredCount: 2,
182 loadBalancer: true,
183});
184
185return { url: nginx.url }; // http://<alb-dns-name>
186```
187
188Networking is optional to start: when `vpcId`/`subnets` are
189omitted the account's **default VPC** (and its per-AZ subnets) is
190used, and when `securityGroups` is omitted with
191`loadBalancer: true`, Alchemy provisions a security group that
192admits the listener port. For a real deployment, build a
193dedicated VPC with the [`Network`](/providers/aws/ec2/network)
194helper and pass `vpcId` + `subnets` — see
195[VPC & networking](/aws/networking).
196
197An effectful `Service` is the server counterpart: where a `Task`
198impl returns `{ run }`, a `Service` impl returns `{ fetch }`:
199
200```typescript
201const api = yield* AWS.ECS.Service(
202 "Api",
203 { cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512 },
204 Effect.gen(function* () {
205 const putItem = yield* AWS.DynamoDB.PutItem(table);
206 return {
207 fetch: Effect.gen(function* () {
208 return yield* HttpServerResponse.json({ ok: true });
209 }),
210 };
211 }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
212);
213```
214
215## Reuse a Task's definition
216
217A `Service` can also deploy an existing `Task`'s definition
218instead of owning an image — shared image, roles, and config; the
219Service adds `desiredCount`, load balancing, and deployment
220configuration:
221
222```typescript
223const api = yield* AWS.ECS.Service("Api", {
224 cluster,
225 task: apiTask,
226 desiredCount: 2,
227 loadBalancer: true,
228});
229```
230
231Most service configuration — desired count, task definition
232revision, network config, deployment settings, load balancers —
233updates **in place** as a rolling deployment; only truly
234immutable aspects (service name, cluster, scheduling strategy,
235deployment controller type, switching between `launchType` and
236`capacityProviderStrategy`) replace the service. For
237cost-sensitive workers, swap `launchType` (default `"FARGATE"`)
238for a `capacityProviderStrategy` mixing `FARGATE_SPOT` and
239`FARGATE` — see the
240[`Service` reference](/providers/aws/ecs/service) for the
241placement, deployment, and Service Connect knobs.
242
243## Run background work
244
245Containers are always-on, so an effectful container can do more
246than answer requests. Yield `ServerHost` and register
247long-running loops with `host.run` — they execute alongside the
248HTTP handler for the life of the container:
249
250```diff lang="typescript"
251import * as AWS from "alchemy/AWS";
252+import { ServerHost } from "alchemy/Server";
253import * as Effect from "effect/Effect";
254+import * as Schedule from "effect/Schedule";
255
256 Effect.gen(function* () {
257+ const host = yield* ServerHost;
258+
259+ yield* host.run(
260+ Effect.log("heartbeat").pipe(
261+ Effect.repeat(Schedule.spaced("30 seconds")),
262+ Effect.asVoid,
263+ ),
264+ );
265+
266 return {
267 fetch: Effect.gen(function* () {
268 // ...
269 }),
270 };
271 }),
272```
273
274Use this for polling loops, queue drainers, or connections that
275stay open across requests.
276
277## Process scope vs request scope
278
279An ECS container is a **real process**, and its instance scope
280reflects that: the bundled program runs under a root scope that
281closes when the process shuts down gracefully, so resources
282acquired in the constructor — the connection a `host.run` loop holds open, a
283warm pool shared across requests — are genuinely released on exit.
284Serverless runtimes only approximate this: workerd never closes
285its instance scope at all, and Lambda gets a best-effort 500 ms
286SIGTERM window; a server gets a real graceful shutdown (a hard
287kill still skips finalizers, as in any process).
288
289Each HTTP request still gets its own request `Scope`, released when
290the response settles — the same per-event contract as every other
291runtime. See
292[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope)
293for the model across all runtimes.
294
295## Bindings
296
297`Task` and `Service` declare the same binding contract as a
298Lambda `Function`: bindings attach **environment variables and
299IAM policy statements**, which Alchemy folds into the container
300environment and the task role. The ECS control-plane bindings
301(`RunTask`, `StopTask`, `ListTasks`, `DescribeTasks`) work from
302Lambda functions and from other containers — useful for a
303function that fans work out to containers.
304
305## Where next
306
307- [Choosing a runtime](/aws/compute/choosing-a-runtime) — when Lambda,
308 EKS, or EC2 fits better than ECS.
309- [VPC & networking](/aws/networking) — what the `Network`
310 helper creates, and the primitives underneath it.
311- [EKS](/aws/compute/eks) — the same platform model on managed
312 Kubernetes.
313- [`Task` reference](/providers/aws/ecs/task),
314 [`Service` reference](/providers/aws/ecs/service),
315 [`Cluster` reference](/providers/aws/ecs/cluster) — every prop
316 and attribute.