Service
Source:
src/AWS/ECS/Service.ts
An ECS service: N copies of a container kept alive, optionally behind a load balancer.
The service’s image comes from one of four sources:
image— run a pre-built registry image, mirrored into ECR.context— build your own Dockerfile.main— bundle an inline Effect program (servers return{ fetch }).task:— deploy an existingAWS.ECS.Task’s definition; the Service addsdesiredCount/ load balancing / deployment configuration.
With any of the first three the Service synthesizes its own task
definition (task + execution roles, log group, ECR repository).
loadBalancer: true wires a public ALB + target group + listener and
populates the url attribute. When vpcId/subnets are omitted the
account’s default VPC (and its per-AZ subnets) is used.
Most configuration is updated in place via updateService
(desiredCount, task definition, network, deployment config, placement,
exec, load balancers, tags). Only truly-immutable aspects — serviceName,
cluster, launchType↔capacityProviderStrategy switch, deploymentController
type, schedulingStrategy, enableECSManagedTags, role — replace the
service.
Creating Services
Section titled “Creating Services”Remote Image Behind a Load Balancer
const nginx = yield* Service("Edge", { cluster, image: "public.ecr.aws/nginx/nginx:1.27", port: 80, desiredCount: 2, loadBalancer: true, // ALB + target group + listener wiring});nginx.url; // http://<alb-dns-name>Run an Existing Task’s Definition
const api = yield* Service("Api", { cluster, task: apiTask, // shared image/roles/config; Service adds desiredCount: 2, // desiredCount / LB / deployment config loadBalancer: true,});Inline Effect Server
const api = yield* 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)),);Bundling & Tree-shaking
Section titled “Bundling & Tree-shaking”main is bundled with rolldown at deploy time. Top-level calls in the
effect, @effect/*, alchemy, @alchemy.run/*, and
@distilled.cloud/* packages receive #__PURE__ annotations by
default, so anything the service doesn’t use from those packages is
tree-shaken out of the bundle. Any other package — including your own
app — is left untouched unless you list it explicitly.
Treat additional packages as pure
Pass package names (or picomatch globs) via build.pure.packages to
annotate them in addition to the defaults.
{ main: import.meta.url, build: { pure: { packages: ["my-lib", "@my-scope/*"] }, },}Listing a package annotates calls whose result is bound (variable
initializers, exports) — safe anywhere. If a listed package also
declares "sideEffects": false (or []) in its package.json, that
combination opts it into full annotation: top-level calls whose result
is discarded (e.g. router.on("/path", handler) registrations) are
also marked pure and deleted under minification when unused. Only list
a sideEffects: false package if its modules really are free of
meaningful top-level side effects. The effect, alchemy, and
@distilled.cloud defaults declare exactly that, on purpose — their
modules are designed to be fully tree-shakeable.
Disable pure annotations
{ main: import.meta.url, build: { pure: false },}Shared Load Balancers
Section titled “Shared Load Balancers”Two Services Sharing One Listener
// The ALB + listener are stack-level resources owned by neither service.const lb = yield* AWS.ELBv2.LoadBalancer("Alb", { subnets: [subnetA.subnetId, subnetB.subnetId], securityGroups: [sg.groupId],});const listener = yield* AWS.ELBv2.Listener("Http", { loadBalancerArn: lb.loadBalancerArn, port: 80, defaultActions: [ { type: "fixedResponse", statusCode: "404", messageBody: "no route" }, ],});
// Each service composes only its own TargetGroup + ListenerRule on the// shared listener. Destroying one service removes its rule + target// group; the ALB, listener, and the other service are untouched.const api = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, loadBalancer: { listener, rules: [{ path: "/api/*" }] },});const web = yield* Service("Web", { cluster, image: "my-org/web:latest", port: 8080, loadBalancer: { listener, rules: [{ path: "/*" }] },});Catch-All on a Shared Listener
// A bare listener reference adds a single `path: "/*"` rule.const svc = yield* Service("Svc", { cluster, image: "my-org/web:latest", port: 8080, loadBalancer: listener,});Owned ALB with Routing Rules and an HTTP → HTTPS Redirect
// `"80/http"`-style `listen` strings mean the service OWNS the ALB and// these listeners (mixing them with shared listener references is a// typed error).const svc = yield* Service("Svc", { cluster, image: "my-org/web:latest", port: 8080, certificateArn, loadBalancer: { rules: [ { listen: "80/http", redirect: "443/https" }, { listen: "443/https", forward: "8080/http" }, ], },});Custom Domains
Section titled “Custom Domains”Domain with a Composed Certificate
// Looks up the matching Route 53 hosted zone, composes a DNS-validated// ACM certificate in the service's region, wires it to the HTTPS// listener, and creates alias A/AAAA records. `url` becomes// https://api.example.com.const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, loadBalancer: { domain: "api.example.com" },});Domain with an Existing Certificate
const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, loadBalancer: { domain: { name: "api.example.com", aliases: ["www.api.example.com"], cert: certificateArn }, },});Network Load Balancers
Section titled “Network Load Balancers”// tcp/udp/tls/tcp_udp listen protocols compose a Network Load Balancer;// each rule's action becomes its listener's default forward (NLB// listeners route by port alone).const svc = yield* Service("Tcp", { cluster, image: "my-org/tcp-echo:latest", port: 9000, loadBalancer: { rules: [{ listen: "80/tcp" }] },});Health Checks
Section titled “Health Checks”Per-Target-Group Health Overrides
const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, loadBalancer: { rules: [{ listen: "80/http" }], health: { "3000/http": { path: "/healthz", interval: "15 seconds", healthyThreshold: 3, successCodes: "200-299", }, }, },});Container Health Check
const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, healthCheck: { command: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"], interval: "30 seconds", retries: 3, },});Autoscaling
Section titled “Autoscaling”// Composes a ScalableTarget (min/max) plus one target-tracking policy// per metric. Redeploys stop pinning desiredCount while scaling is set.const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, loadBalancer: true, scaling: { min: 1, max: 4, cpuUtilization: 70, requestCount: 200, scaleInCooldown: "5 minutes", },});Secrets & Logging
Section titled “Secrets & Logging”// Values are ARNs; the container gets them as env vars via `valueFrom`// and the execution role is granted read on exactly these ARNs.const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, secrets: { DB_PASSWORD: dbPasswordSecret.secretArn, API_KEY: apiKeyParameter.parameterArn, }, logging: { retention: "2 weeks" },});Service Discovery
Section titled “Service Discovery”const namespace = yield* AWS.CloudMap.PrivateDnsNamespace("AppNs", { name: "internal.example.com", vpc: vpc.vpcId,});const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, serviceRegistry: { namespace },});Volumes
Section titled “Volumes”const svc = yield* Service("Api", { cluster, image: "my-org/api:latest", port: 3000, volumes: [{ efs: fileSystem, path: "/mnt/data" }],});Capacity
Section titled “Capacity”// The cluster must have the Fargate capacity providers associated:// Cluster("C", { capacityProviders: ["FARGATE", "FARGATE_SPOT"] }).const svc = yield* Service("Worker", { cluster, image: "my-org/worker:latest", capacity: { fargate: { weight: 1, base: 1 }, spot: { weight: 4 } },});Load Balancing
Section titled “Load Balancing”const service = yield* Service("ApiService", { cluster, task: apiTask, vpcId: vpc.vpcId, subnets: [subnet1.subnetId, subnet2.subnetId], loadBalancers: [ { targetGroupArn, containerName: apiTask.containerName, containerPort: apiTask.port, }, ],});Capacity & Placement
Section titled “Capacity & Placement”const service = yield* Service("WorkerService", { cluster, task: workerTask, vpcId: vpc.vpcId, subnets: [subnet.subnetId], capacityProviderStrategy: [ { capacityProvider: "FARGATE_SPOT", weight: 4 }, { capacityProvider: "FARGATE", weight: 1, base: 1 }, ], placementStrategy: [{ type: "spread", field: "attribute:ecs.availability-zone" }],});Deployment
Section titled “Deployment”const service = yield* Service("ApiService", { cluster, task: apiTask, vpcId: vpc.vpcId, subnets: [subnet1.subnetId, subnet2.subnetId], desiredCount: 3, enableExecuteCommand: true, deploymentConfiguration: { minimumHealthyPercent: 100, maximumPercent: 200, deploymentCircuitBreaker: { enable: true, rollback: true }, }, healthCheckGracePeriod: "30 seconds",});