Skip to content

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 existing AWS.ECS.Task’s definition; the Service adds desiredCount / 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.

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

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 },
}

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

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

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,
},
});
// 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",
},
});
// 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" },
});
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 },
});
const svc = yield* Service("Api", {
cluster,
image: "my-org/api:latest",
port: 3000,
volumes: [{ efs: fileSystem, path: "/mnt/data" }],
});
// 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 } },
});
const service = yield* Service("ApiService", {
cluster,
task: apiTask,
vpcId: vpc.vpcId,
subnets: [subnet1.subnetId, subnet2.subnetId],
loadBalancers: [
{
targetGroupArn,
containerName: apiTask.containerName,
containerPort: apiTask.port,
},
],
});
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" }],
});
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",
});