Containerize and Deploy with Pulumi
Turn tested ParcelFlow services into immutable Bun containers and a reviewable AWS release with canary rollback.
The enterprise problem and today’s slice
Enterprise problem: A service that passes local tests can still fail customers when its image is mutable, network is overexposed, database migration races, task role is broad, secret is copied into configuration, autoscaling ignores backpressure, or a deployment has no observed rollback path.
Whole-course context: The incoming ParcelFlow system has tenant-safe authority and an operating evidence loop; this day packages those same service contracts and evidence endpoints into a production-shaped runtime before the final adversarial proof.
Today’s slice: Build pinned Bun OCI images, declare Amazon ECS on Fargate, an Application Load Balancer, Amazon RDS for PostgreSQL, Amazon SQS event transport, Secrets Manager references, least-privilege IAM, and target tracking with Pulumi TypeScript using its Bun language runtime.
End-of-day evidence: A reviewed Pulumi preview, immutable image digests, deployed stack outputs, healthy v1 probes, a bounded v2 canary, a deliberately failed canary, and a verified pointer rollback all share stack, release, task, trace, and artifact IDs.
Still unsolved: The complete positive, duplicate, out-of-stock, timeout, replay, tenant-denial, evidence-lineage, rollback, export, revoke, ingress-disable, and teardown matrix remains for the capstone.
Customer use cases
Infrastructure exists to deliver a safe customer release, not to maximize resource count. These cases cover initial deployment and controlled change without conflating Pulumi execution with application execution.
| Use case ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D11-UC-01 | Platform engineer | Deploy ParcelFlow from reviewed source and immutable images | Pulumi creates private service tasks, PostgreSQL, event queues, secret references, least-privilege roles, ingress, health checks, and stack outputs; a customer order probe passes | Preview blocks public database access, plaintext secrets, wildcard IAM, mutable image tags, or destructive replacement without approval |
| D11-UC-02 | Release operator | Promote a new service version without losing customer work | A small v2 canary receives bounded traffic, passes tenant, order, queue, and SLO probes, then becomes active by immutable digest | A failed v2 probe returns traffic to v1; existing RDS data and durable SQS messages remain intact and rollback evidence is retained |
Actor-centred user stories
Deployment automation is safe only when the responsible actors can inspect desired change and observe the runtime result.
| Story ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D11-US-01 | D11-UC-01 | As a platform engineer, I want infrastructure and workload identity declared together, so that every service receives only its network, database, queue, and secret capabilities | pulumi preview --diff shows bounded resources; task-role policies name exact resources; containers run by digest in private subnets; public access reaches only the load balancer |
| D11-US-02 | D11-UC-02 | As a release operator, I want a canary and rollback controlled by observed customer probes, so that a bad image cannot silently replace the last known-good release | Canary weight and digest are recorded, required probes run against v2, failed evidence restores v1 weight, and database/message state is not rolled back with code |
End-to-end product flows
Deployment has two runtimes and two evidence loops: Pulumi evaluates desired infrastructure in CI, while Bun runs ParcelFlow inside ECS tasks. The flows make that distinction and the rollback boundary explicit.
| Flow ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D11-FLOW-01 | D11-UC-01 | Happy | Platform engineer approves a reviewed release candidate | 1. CI installs pinned Bun and Pulumi versions.<br>2. Tests build OCI images and record digests.<br>3. Pulumi Bun language runtime evaluates desired AWS resources.<br>4. Policy checks inspect preview.<br>5. Approved update creates or reconciles network, data, queues, secrets, roles, and ECS services.<br>6. Runtime probes exercise one customer order. | Source commit, lockfile, Bun/Pulumi versions, image digests, preview/update IDs, stack outputs, task revisions, migration ID, trace, environment, timestamp, and HTTP result |
| D11-FLOW-02 | D11-UC-02 | Recovery | v2 canary fails the confirmation or tenant-denial probe | 1. Release controller registers v2 target by digest.<br>2. Listener sends bounded traffic to v2.<br>3. Probe suite observes failure and unaffected v1 control.<br>4. Controller sets v2 weight to zero and restores v1.<br>5. ECS drains v2 gracefully.<br>6. Queue and database checks prove durable state survived.<br>7. Release record closes as rolled back. | Failed probe, canary weights, v1/v2 digests, rollback update ID, drain result, RDS order state, SQS depth/replay, customer recovery trace, environment, and timestamp |
System design derived from the flows
A production diagram must show authority and failure boundaries, not only AWS icons. Public ingress, private tasks, data stores, event transport, secret resolution, and deployment control each retain separate ownership.
| Use case ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D11-UC-01 | CI release workflow and Pulumi stack | OCI builder, registry, Pulumi engine, AWS provider, VPC, load balancer, ECS services, RDS, SQS, Secrets Manager, IAM, autoscaling, migration task, smoke probe | Git owns desired source; registry owns image bytes/digests; Pulumi state owns desired resource graph; AWS services own observed runtime state; RDS owns ParcelFlow domain data | Build/test failure, policy rejection, preview replacement, provider error, unhealthy task, failed migration, denied IAM action, queue alarm, or smoke failure |
| D11-UC-02 | Release-controller canary action | ALB weighted routing, v1/v2 ECS services, probe runner, SLO evaluator, rollback controller, graceful drain, release evidence writer | Release store owns intended active digest/weights; ALB/ECS own observed routing/tasks; RDS and SQS remain domain/event authorities | Canary probe failure, SLO burn, digest mismatch, drain timeout, rollback update failure, missing durable state, or failed v1 positive control |
Data model and ownership
An infrastructure stack does not own customer orders merely because it creates the database resource. Desired infrastructure state, observed runtime state, domain data, release decisions, and secret material remain separate lifecycle objects.
Generated-application database: Required in this slice — Amazon RDS for PostgreSQL hosts the existing tenant-scoped ParcelFlow order, outbox, inventory, and evidence records under their owning services.
| Record or entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| InfrastructureStack | Pulumi backend owned by platform engineering | project/stack plus update version | Opaque source commit, provider, account, region, and policy-pack references | None — stack is environment-scoped and contains no customer payload | Encrypted state, locked update, reviewed preview, and explicit protection/deletion policy required | Create and update declaratively; export state for recovery; destroy only after data and ingress lifecycle prerequisites | D11-UC-01, D11-UC-02 |
| ImageArtifact | OCI registry owned by release engineering | Repository plus content digest | Opaque source commit, lockfile, SBOM, test-run, and signature references | None — image contains code, never tenant data | Deployment uses digest, vulnerability policy and provenance; mutable tag cannot authorize release | Build once, sign, retain through rollback window, expire unreferenced artifacts by policy | D11-UC-01, D11-UC-02 |
| ServiceDeployment | ECS and release stores owned by service platform | service_name/task_definition_revision | Opaque image digest, task role, network, secret-version, and stack-update references | None — deployment serves many tenants but receives no automatic tenant authority | Desired/observed digest, health, capacity, graceful-stop window, and task role must match release | Register, canary, promote, drain, roll back; retain revision evidence, deregister after rollback window | D11-UC-01, D11-UC-02 |
| ParcelFlowDatabase | RDS PostgreSQL owned by ParcelFlow data services | RDS resource ID plus database/schema revision | Opaque stack and secret references; local tenant-aware domain relationships | tenant_id inside every owned domain row | Private network, encryption, backup, migration compatibility, RLS, and deletion protection required | Snapshot and restore by policy; schema expands compatibly; delete only after export/retention approval | D11-UC-01, D11-UC-02 |
| EventQueue | SQS owned by event platform | Queue ARN | Opaque DLQ, producer role, consumer role, and event-schema references | Tenant carried inside authorized envelope, not queue IAM identity | Encryption, redrive policy, bounded retention, least-privilege send/receive, idempotent consumer required | Drain or redrive; retain per event policy; delete only after publishers stop and backlog reaches zero | D11-UC-01, D11-UC-02 |
| SecretReference | Secrets Manager owned by security platform | Secret ARN plus version ID | Opaque service deployment and rotation references | None — credential is service/environment scoped, never customer identity | Runtime resolves by task role; value absent from source, Pulumi output, logs, and image | Rotate and revoke; destroy retired value after grace period; retain only non-secret audit metadata | D11-UC-01 |
| ReleaseTransition | Release store owned by release controller | release_transition_id | Opaque v1/v2 digests, ALB weights, probe run, Pulumi update, and actor references | None — release is environment-wide; probes contain separately scoped tenant fixtures | State machine, separation of approval, exact digests, terminal evidence, and rollback target required | Candidate, canary, promoted or rolled back; append-only retention through audit policy | D11-UC-02 |
Distinguish the Pulumi runtime from the workload runtime
The general rule is that the Pulumi language runtime evaluates infrastructure code, while the workload runtime executes customer-facing application code. Choosing Bun for one does not require choosing it for the other.
Pulumi documents Bun as a first-class TypeScript/JavaScript runtime from Pulumi 3.227.0 and requires Bun 1.3 or later for that path. It also documents a real limitation: function serialization and dynamic providers depend on Node V8/inspector behavior and are unsupported with runtime: bun. A simple resource program can use Bun; a program that needs those features should use Pulumi's nodejs runtime, optionally with Bun as package manager, while ParcelFlow still runs on Bun in ECS.
name: parcelflow
description: ParcelFlow AWS infrastructure
runtime: bun
config:
aws:region: eu-west-2
Decision rule: select each runtime from the APIs it must execute, and prove the choice in CI; never infer workload compatibility from a successful infrastructure preview.
Build an immutable Bun container
A container tag can move, so the release record must resolve it to a content digest. The official Bun Docker guidance uses staged dependency installation, tests before release, the oven/bun image, and a non-root bun user.
FROM oven/bun:1.3.0-slim AS install
WORKDIR /srv/app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.0-slim AS production-install
WORKDIR /srv/app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
FROM install AS verify
COPY . .
RUN bun test
RUN bun run type-check
FROM oven/bun:1.3.0-slim AS release
WORKDIR /srv/app
ENV NODE_ENV=production
COPY --from=production-install /srv/app/node_modules ./node_modules
COPY --from=verify /srv/app/src ./src
COPY --from=verify /srv/app/package.json ./package.json
USER bun
EXPOSE 3000
ENTRYPOINT ["bun", "run", "src/index.ts"]
Build and inspect before publishing:
docker build --pull --target verify --tag parcelflow-orders:verify .
docker run --rm parcelflow-orders:verify bun test
docker build --pull --tag parcelflow-orders:candidate .
docker image inspect --format '{{.Id}}' parcelflow-orders:candidate
The release stage contains no .env, cloud credential, test fixture, or tenant data. CI records the resolved base-image and output-image digests; production task definitions reference the latter digest.
Declare the complete runnable AWS stack
Shared task roles undermine service authority, while a queue without a consumer or a task definition without networking is not a deployable system. This compact program is the complete Day 11 baseline: two Availability Zones, public ALB subnets, private Fargate/RDS subnets with controlled egress, managed database credentials, service discovery, durable queues, per-service roles, digest-pinned tasks, a migration task, stable/canary target groups, and request- plus backlog-aware scaling. A production organization may split these resources into components, but must preserve the same graph and constraints.
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const cfg = new pulumi.Config();
const stableImage = cfg.require("stableImageDigest"); // registry/repository@sha256:...
const canaryImage = cfg.require("canaryImageDigest");
const certificateArn = cfg.require("certificateArn");
const publicHostname = cfg.require("publicHostname");
const hostedZoneId = cfg.require("hostedZoneId");
const canaryWeight = cfg.getNumber("canaryWeight") ?? 0;
const protectData = cfg.getBoolean("protectData") ?? true;
const enableTestControl = cfg.getBoolean("enableTestControl") ?? false;
if (enableTestControl && protectData) throw new Error("test control is forbidden on protected stacks");
if (canaryWeight < 0 || canaryWeight > 10) throw new Error("canaryWeight must be 0..10");
const region = aws.getRegionOutput().name;
const zones = await aws.getAvailabilityZones({ state: "available" });
if (zones.names.length < 2) throw new Error("two availability zones required");
const vpc = new aws.ec2.Vpc("parcelflow", {
cidrBlock: "10.42.0.0/16",
enableDnsHostnames: true,
enableDnsSupport: true,
tags: { Environment: pulumi.getStack(), Disposable: String(!protectData) },
});
const internetGateway = new aws.ec2.InternetGateway("parcelflow", { vpcId: vpc.id });
const publicSubnets: aws.ec2.Subnet[] = [];
const privateSubnets: aws.ec2.Subnet[] = [];
for (let index = 0; index < 2; index++) {
const publicSubnet = new aws.ec2.Subnet(`public-${index}`, {
vpcId: vpc.id,
availabilityZone: zones.names[index],
cidrBlock: `10.42.${index}.0/24`,
mapPublicIpOnLaunch: true,
});
const privateSubnet = new aws.ec2.Subnet(`private-${index}`, {
vpcId: vpc.id,
availabilityZone: zones.names[index],
cidrBlock: `10.42.${index + 10}.0/24`,
});
publicSubnets.push(publicSubnet);
privateSubnets.push(privateSubnet);
}
const publicRoutes = new aws.ec2.RouteTable("public", {
vpcId: vpc.id,
routes: [{ cidrBlock: "0.0.0.0/0", gatewayId: internetGateway.id }],
});
publicSubnets.forEach((subnet, index) => new aws.ec2.RouteTableAssociation(`public-${index}`, {
subnetId: subnet.id,
routeTableId: publicRoutes.id,
}));
const natAddress = new aws.ec2.Eip("nat", { domain: "vpc" }, { dependsOn: internetGateway });
const nat = new aws.ec2.NatGateway("nat", {
allocationId: natAddress.id,
subnetId: publicSubnets[0].id,
});
const privateRoutes = new aws.ec2.RouteTable("private", {
vpcId: vpc.id,
routes: [{ cidrBlock: "0.0.0.0/0", natGatewayId: nat.id }],
});
privateSubnets.forEach((subnet, index) => new aws.ec2.RouteTableAssociation(`private-${index}`, {
subnetId: subnet.id,
routeTableId: privateRoutes.id,
}));
const albSecurityGroup = new aws.ec2.SecurityGroup("alb", {
vpcId: vpc.id,
ingress: [
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
{ protocol: "tcp", fromPort: 443, toPort: 443, cidrBlocks: ["0.0.0.0/0"] },
],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
const taskSecurityGroup = new aws.ec2.SecurityGroup("tasks", {
vpcId: vpc.id,
ingress: [
{ protocol: "tcp", fromPort: 3000, toPort: 3000, securityGroups: [albSecurityGroup.id] },
{ protocol: "tcp", fromPort: 3000, toPort: 3000, self: true },
],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
const databaseSecurityGroup = new aws.ec2.SecurityGroup("database", {
vpcId: vpc.id,
ingress: [{ protocol: "tcp", fromPort: 5432, toPort: 5432, securityGroups: [taskSecurityGroup.id] }],
egress: [],
});
const databaseSubnets = new aws.rds.SubnetGroup("database", {
subnetIds: privateSubnets.map(({ id }) => id),
});
const database = new aws.rds.Instance("parcelflow", {
engine: "postgres",
engineVersion: "16",
instanceClass: "db.t4g.micro",
allocatedStorage: 20,
dbName: "parcelflow",
username: "parcelflow_runtime",
manageMasterUserPassword: true,
dbSubnetGroupName: databaseSubnets.name,
vpcSecurityGroupIds: [databaseSecurityGroup.id],
publiclyAccessible: false,
storageEncrypted: true,
backupRetentionPeriod: 7,
deletionProtection: protectData,
skipFinalSnapshot: !protectData,
});
const databaseSecretArn = database.masterUserSecrets.apply((secrets) => {
if (!secrets[0]) throw new Error("RDS did not publish its managed secret ARN");
return secrets[0].secretArn;
});
const deadLetterQueue = new aws.sqs.Queue("order-events-dlq", {
messageRetentionSeconds: 1209600,
sqsManagedSseEnabled: true,
});
const eventQueue = new aws.sqs.Queue("order-events", {
messageRetentionSeconds: 345600,
visibilityTimeoutSeconds: 60,
redrivePolicy: pulumi.jsonStringify({ deadLetterTargetArn: deadLetterQueue.arn, maxReceiveCount: 5 }),
sqsManagedSseEnabled: true,
});
const assumeTask = aws.iam.assumeRolePolicyForPrincipal({ Service: "ecs-tasks.amazonaws.com" });
const executionRole = new aws.iam.Role("task-execution", { assumeRolePolicy: assumeTask });
new aws.iam.RolePolicyAttachment("task-execution-managed", {
role: executionRole.name,
policyArn: aws.iam.ManagedPolicy.AmazonECSTaskExecutionRolePolicy,
});
new aws.iam.RolePolicy("task-read-db-secret", {
role: executionRole.id,
policy: databaseSecretArn.apply((arn) => JSON.stringify({
Version: "2012-10-17",
Statement: [{ Effect: "Allow", Action: ["secretsmanager:GetSecretValue"], Resource: arn }],
})),
});
function taskRole(name: string): aws.iam.Role {
return new aws.iam.Role(`${name}-task`, { assumeRolePolicy: assumeTask });
}
const ordersRole = taskRole("orders");
const inventoryRole = taskRole("inventory");
const fulfillmentRole = taskRole("fulfillment");
new aws.iam.RolePolicy("orders-send-events", {
role: ordersRole.id,
policy: eventQueue.arn.apply((arn) => JSON.stringify({
Version: "2012-10-17",
Statement: [{ Effect: "Allow", Action: ["sqs:SendMessage"], Resource: arn }],
})),
});
new aws.iam.RolePolicy("fulfillment-consume-events", {
role: fulfillmentRole.id,
policy: eventQueue.arn.apply((arn) => JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:ChangeMessageVisibility", "sqs:GetQueueAttributes"],
Resource: arn,
}],
})),
});
const cluster = new aws.ecs.Cluster("parcelflow");
const logs = new aws.cloudwatch.LogGroup("parcelflow", { retentionInDays: 14 });
const namespace = new aws.servicediscovery.PrivateDnsNamespace("parcelflow", {
name: "parcelflow.local",
vpc: vpc.id,
});
const inventoryDiscovery = new aws.servicediscovery.Service("inventory", {
dnsConfig: {
namespaceId: namespace.id,
routingPolicy: "MULTIVALUE",
dnsRecords: [{ ttl: 10, type: "A" }],
},
healthCheckCustomConfig: { failureThreshold: 1 },
});
function taskDefinition(name: string, image: pulumi.Input<string>, role: aws.iam.Role, command?: string[]) {
return new aws.ecs.TaskDefinition(name, {
family: `parcelflow-${name}`,
requiresCompatibilities: ["FARGATE"],
networkMode: "awsvpc",
cpu: "256",
memory: "512",
executionRoleArn: executionRole.arn,
taskRoleArn: role.arn,
containerDefinitions: pulumi.jsonStringify([{
name,
image,
essential: true,
command,
portMappings: [{ containerPort: 3000, protocol: "tcp" }],
environment: [
{ name: "SERVICE_NAME", value: name },
{ name: "DATABASE_HOST", value: database.address },
{ name: "DATABASE_NAME", value: "parcelflow" },
{ name: "DATABASE_USER", value: "parcelflow_runtime" },
{ name: "INVENTORY_BASE_URL", value: "http://inventory.parcelflow.local:3000" },
{ name: "ORDER_EVENTS_QUEUE_URL", value: eventQueue.url },
{ name: "ENABLE_TEST_CONTROL", value: String(enableTestControl) },
],
secrets: [{
name: "DATABASE_PASSWORD",
valueFrom: databaseSecretArn.apply((arn) => `${arn}:password::`),
}],
logConfiguration: {
logDriver: "awslogs",
options: { "awslogs-group": logs.name, "awslogs-region": region, "awslogs-stream-prefix": name },
},
healthCheck: { command: ["CMD-SHELL", "bun -e \"await fetch('http://localhost:3000/health/ready').then(r=>{if(!r.ok)process.exit(1)})\""], interval: 30, timeout: 5, retries: 3 },
}]),
});
}
const ordersStableTask = taskDefinition("orders-stable", stableImage, ordersRole);
const ordersCanaryTask = taskDefinition("orders-canary", canaryImage, ordersRole);
const inventoryTask = taskDefinition("inventory", stableImage, inventoryRole);
const fulfillmentTask = taskDefinition("fulfillment", stableImage, fulfillmentRole);
const migrationTask = taskDefinition("migration", stableImage, ordersRole, ["bun", "run", "src/migrate.ts"]);
const alb = new aws.lb.LoadBalancer("parcelflow", {
loadBalancerType: "application",
securityGroups: [albSecurityGroup.id],
subnets: publicSubnets.map(({ id }) => id),
});
new aws.route53.Record("parcelflow", {
zoneId: hostedZoneId,
name: publicHostname,
type: "A",
aliases: [{ name: alb.dnsName, zoneId: alb.zoneId, evaluateTargetHealth: true }],
});
function targetGroup(name: string) {
return new aws.lb.TargetGroup(name, {
vpcId: vpc.id,
port: 3000,
protocol: "HTTP",
targetType: "ip",
healthCheck: { path: "/health/ready", matcher: "200", interval: 15 },
deregistrationDelay: 30,
});
}
const stableTargets = targetGroup("orders-stable");
const canaryTargets = targetGroup("orders-canary");
const listener = new aws.lb.Listener("https", {
loadBalancerArn: alb.arn,
port: 443,
protocol: "HTTPS",
certificateArn,
sslPolicy: "ELBSecurityPolicy-TLS13-1-2-2021-06",
defaultActions: [{
type: "forward",
forward: {
targetGroups: [
{ arn: stableTargets.arn, weight: 100 - canaryWeight },
{ arn: canaryTargets.arn, weight: canaryWeight },
],
},
}],
});
new aws.lb.Listener("http-redirect", {
loadBalancerArn: alb.arn,
port: 80,
protocol: "HTTP",
defaultActions: [{ type: "redirect", redirect: { protocol: "HTTPS", port: "443", statusCode: "HTTP_301" } }],
});
new aws.lb.ListenerRule("direct-canary-probe", {
listenerArn: listener.arn,
priority: 10,
actions: [{ type: "forward", targetGroupArn: canaryTargets.arn }],
conditions: [{ httpHeader: { httpHeaderName: "x-release-track", values: ["canary"] } }],
});
const network = { subnets: privateSubnets.map(({ id }) => id), securityGroups: [taskSecurityGroup.id] };
function service(name: string, task: aws.ecs.TaskDefinition, desiredCount: pulumi.Input<number>, target?: aws.lb.TargetGroup) {
return new aws.ecs.Service(name, {
cluster: cluster.arn,
taskDefinition: task.arn,
desiredCount,
launchType: "FARGATE",
deploymentMinimumHealthyPercent: 100,
deploymentMaximumPercent: 200,
enableExecuteCommand: false,
networkConfiguration: { ...network, assignPublicIp: false },
loadBalancers: target ? [{ targetGroupArn: target.arn, containerName: name, containerPort: 3000 }] : undefined,
}, { dependsOn: target ? listener : undefined });
}
const ordersStable = service("orders-stable", ordersStableTask, 2, stableTargets);
service("orders-canary", ordersCanaryTask, canaryWeight > 0 ? 1 : 0, canaryTargets);
new aws.ecs.Service("inventory", {
cluster: cluster.arn,
taskDefinition: inventoryTask.arn,
desiredCount: 2,
launchType: "FARGATE",
networkConfiguration: { ...network, assignPublicIp: false },
serviceRegistries: { registryArn: inventoryDiscovery.arn },
});
const fulfillment = service("fulfillment", fulfillmentTask, 2);
const ordersCapacity = new aws.appautoscaling.Target("orders-capacity", {
maxCapacity: 20,
minCapacity: 2,
resourceId: pulumi.interpolate`service/${cluster.name}/${ordersStable.name}`,
scalableDimension: "ecs:service:DesiredCount",
serviceNamespace: "ecs",
});
new aws.appautoscaling.Policy("orders-cpu", {
policyType: "TargetTrackingScaling",
resourceId: ordersCapacity.resourceId,
scalableDimension: ordersCapacity.scalableDimension,
serviceNamespace: ordersCapacity.serviceNamespace,
targetTrackingScalingPolicyConfiguration: {
targetValue: 60,
predefinedMetricSpecification: { predefinedMetricType: "ECSServiceAverageCPUUtilization" },
},
});
const fulfillmentCapacity = new aws.appautoscaling.Target("fulfillment-capacity", {
maxCapacity: 20,
minCapacity: 2,
resourceId: pulumi.interpolate`service/${cluster.name}/${fulfillment.name}`,
scalableDimension: "ecs:service:DesiredCount",
serviceNamespace: "ecs",
});
new aws.appautoscaling.Policy("fulfillment-backlog", {
policyType: "TargetTrackingScaling",
resourceId: fulfillmentCapacity.resourceId,
scalableDimension: fulfillmentCapacity.scalableDimension,
serviceNamespace: fulfillmentCapacity.serviceNamespace,
targetTrackingScalingPolicyConfiguration: {
targetValue: 20,
customizedMetricSpecification: {
namespace: "AWS/SQS",
metricName: "ApproximateNumberOfMessagesVisible",
statistic: "Average",
dimensions: [{ name: "QueueName", value: eventQueue.name }],
},
},
});
export const albUrl = `https://${publicHostname}`;
export const clusterArn = cluster.arn;
export const migrationTaskDefinitionArn = migrationTask.arn;
export const runtimeNetworkJson = pulumi.jsonStringify({
awsvpcConfiguration: {
subnets: privateSubnets.map(({ id }) => id),
securityGroups: [taskSecurityGroup.id],
assignPublicIp: "DISABLED",
},
});
export const release = { stableImage, canaryImage, canaryWeight };
export const environmentMarker = protectData ? "protected" : "disposable-e2e";
Save this program as infra/index.ts beside the Day 11 Pulumi.yaml; it is the referenced infrastructure implementation for the capstone. The single NAT gateway keeps the example compact; a production availability review should choose one per Availability Zone or private AWS service endpoints. The image supplies the Day 04 migration entrypoint, while Pulumi declares but does not automatically race that task against application replicas. Database schema ownership still belongs to the migration role and migration artifact, not to ECS startup.
Preview, canary, and roll back
Apply is not the first review surface. Preview and policy checks reject unsafe changes before provider mutation; post-update probes decide whether the observed system matches the approved design.
pulumi version
bun --version
pulumi install
pulumi preview --diff --save-plan candidate.plan
pulumi up --yes --plan candidate.plan
CLUSTER=$(pulumi stack output clusterArn)
MIGRATION_TASK=$(pulumi stack output migrationTaskDefinitionArn)
NETWORK=$(pulumi stack output runtimeNetworkJson)
RUN_TASK=$(aws ecs run-task --cluster "$CLUSTER" --launch-type FARGATE --task-definition "$MIGRATION_TASK" --network-configuration "$NETWORK" --query 'tasks[0].taskArn' --output text)
aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks "$RUN_TASK"
test "$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$RUN_TASK" --query 'tasks[0].containers[0].exitCode' --output text)" = "0"
BASE_URL=$(pulumi stack output albUrl)
curl --fail --show-error --silent "$BASE_URL/health/ready"
pulumi config set canaryWeight 5
pulumi preview --diff
pulumi up --yes
mkdir -p evidence
CANARY_STATUS=$(curl --show-error --silent --output evidence/canary-body.json --write-out '%{http_code}' -H 'x-release-track: canary' "$BASE_URL/health/ready")
test "$CANARY_STATUS" != "200"
pulumi config set canaryWeight 0
pulumi preview --diff
pulumi up --yes
curl --fail --show-error --silent "$BASE_URL/health/ready"
The canary digest is an intentionally failing v2 fixture. The header rule makes its probe deterministic while ordinary traffic remains bounded by the configured weight. Rollback sets customer traffic to stable-only and scales the canary service to zero; it changes executable routing, not RDS customer data or SQS durable messages. ECS readiness turns false, ALB stops new traffic, in-flight work drains, and replayable work remains in the queue. CI must create the evidence directory and retain the preview, migration task, canary response, before/after listener state, and state-preservation queries.
Failure modes, trade-offs, and decision rule
Fargate reduces host operations but limits host-level control; RDS reduces database operations but does not own schema correctness; SQS supplies durable delivery but still requires idempotent consumers and explicit ordering assumptions. Autoscaling can improve throughput while multiplying database connections or downstream pressure. Set capacity from measured request, latency, queue-age, connection, and cost constraints, then load-test the entire bottleneck chain.
Decision rule: approve deployment only when desired resources, runtime identities, immutable artifacts, migration compatibility, observed health, customer probes, rollback target, and data/message preservation all agree; a successful pulumi up is necessary evidence, never sufficient evidence.
Primary sources
Deployment guidance changes with Bun, Pulumi, AWS, and container releases, so version-sensitive behavior must not rely on an undated tutorial. These maintained primary sources define the claims that the pinned preview and runtime probes must confirm.
Key takeaways
Deployment automation becomes unsafe when infrastructure evaluation, workload execution, and customer-state ownership are conflated. Retain these boundaries when changing cloud provider, runtime, or release controller.
- Pulumi's Bun language runtime and ParcelFlow's Bun workload runtime are separate choices with separate compatibility evidence.
- Deploy immutable image digests, private tasks, least-privilege roles, managed secret references, durable state, and observable health.
- Pulumi Bun runtime does not support function serialization or dynamic providers; use Node.js for the infrastructure program when those features are required.
- Canary rollback changes executable routing while preserving compatible customer data and durable events.
Checklist
A successful provider update does not prove a safe customer release, because identity, health, migration, canary, and rollback can still disagree. Use this list to require both desired-state and observed-runtime evidence.
- [ ] Bun, Pulumi, providers, dependencies, base images, and application images are pinned and recorded.
- [ ] Preview and policy checks reject public RDS, plaintext secrets, wildcard IAM, mutable images, and unsafe replacement.
- [ ] Orders, Inventory, and Fulfillment have distinct task roles and network permissions.
- [ ] RDS backups/deletion protection, SQS redrive, secret rotation, health checks, graceful stop, and autoscaling alarms are declared.
- [ ] Healthy v1, bounded v2 canary, failed v2, v1 rollback, database preservation, and queue replay are observed.
- [ ] Every release transition retains source, artifact, infrastructure, runtime, probe, actor, environment, and timestamp identifiers.