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.
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.
Run a server from an image
Section titled “Run a server from an image”Declare a cluster, then a Service that runs an image. loadBalancer: true puts a public Application Load Balancer in front of it:
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.
Run your own program
Section titled “Run your own program”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:
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.
Run a job to completion
Section titled “Run a job to completion”A Task on its own runs until its process exits. Give it a run Effect instead of fetch:
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:
const runTask = yield* AWS.ECS.RunTask(cluster, reindex);yield* runTask({ launchType: "FARGATE", networkConfiguration: { awsvpcConfiguration: { subnets: network.privateSubnetIds } },});Run a job on a schedule
Section titled “Run a job on a schedule”ECS.every runs a Task on a rate or a cron expression, with the role the schedule needs to start it:
yield* AWS.ECS.every("NightlyReindex", "cron(0 3 * * ? *)", { cluster, task: reindex, subnets: network.privateSubnetIds, securityGroups: [jobs.groupId],});Do work in the background
Section titled “Do work in the background”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:
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.
Process scope and request scope
Section titled “Process scope and request scope”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.
Deploy changes
Section titled “Deploy changes”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.
Pay less for workers
Section titled “Pay less for workers”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:
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.