11

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 IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D11-UC-01Platform engineerDeploy ParcelFlow from reviewed source and immutable imagesPulumi creates private service tasks, PostgreSQL, event queues, secret references, least-privilege roles, ingress, health checks, and stack outputs; a customer order probe passesPreview blocks public database access, plaintext secrets, wildcard IAM, mutable image tags, or destructive replacement without approval
D11-UC-02Release operatorPromote a new service version without losing customer workA small v2 canary receives bounded traffic, passes tenant, order, queue, and SLO probes, then becomes active by immutable digestA 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 IDUse case IDsUser storyObservable acceptance conditions
D11-US-01D11-UC-01As a platform engineer, I want infrastructure and workload identity declared together, so that every service receives only its network, database, queue, and secret capabilitiespulumi 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-02D11-UC-02As 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 releaseCanary 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 IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D11-FLOW-01D11-UC-01HappyPlatform engineer approves a reviewed release candidate1. 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-02D11-UC-02Recoveryv2 canary fails the confirmation or tenant-denial probe1. 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 IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D11-UC-01CI release workflow and Pulumi stackOCI builder, registry, Pulumi engine, AWS provider, VPC, load balancer, ECS services, RDS, SQS, Secrets Manager, IAM, autoscaling, migration task, smoke probeGit owns desired source; registry owns image bytes/digests; Pulumi state owns desired resource graph; AWS services own observed runtime state; RDS owns ParcelFlow domain dataBuild/test failure, policy rejection, preview replacement, provider error, unhealthy task, failed migration, denied IAM action, queue alarm, or smoke failure
D11-UC-02Release-controller canary actionALB weighted routing, v1/v2 ECS services, probe runner, SLO evaluator, rollback controller, graceful drain, release evidence writerRelease store owns intended active digest/weights; ALB/ECS own observed routing/tasks; RDS and SQS remain domain/event authoritiesCanary 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 entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
InfrastructureStackPulumi backend owned by platform engineeringproject/stack plus update versionOpaque source commit, provider, account, region, and policy-pack referencesNone — stack is environment-scoped and contains no customer payloadEncrypted state, locked update, reviewed preview, and explicit protection/deletion policy requiredCreate and update declaratively; export state for recovery; destroy only after data and ingress lifecycle prerequisitesD11-UC-01, D11-UC-02
ImageArtifactOCI registry owned by release engineeringRepository plus content digestOpaque source commit, lockfile, SBOM, test-run, and signature referencesNone — image contains code, never tenant dataDeployment uses digest, vulnerability policy and provenance; mutable tag cannot authorize releaseBuild once, sign, retain through rollback window, expire unreferenced artifacts by policyD11-UC-01, D11-UC-02
ServiceDeploymentECS and release stores owned by service platformservice_name/task_definition_revisionOpaque image digest, task role, network, secret-version, and stack-update referencesNone — deployment serves many tenants but receives no automatic tenant authorityDesired/observed digest, health, capacity, graceful-stop window, and task role must match releaseRegister, canary, promote, drain, roll back; retain revision evidence, deregister after rollback windowD11-UC-01, D11-UC-02
ParcelFlowDatabaseRDS PostgreSQL owned by ParcelFlow data servicesRDS resource ID plus database/schema revisionOpaque stack and secret references; local tenant-aware domain relationshipstenant_id inside every owned domain rowPrivate network, encryption, backup, migration compatibility, RLS, and deletion protection requiredSnapshot and restore by policy; schema expands compatibly; delete only after export/retention approvalD11-UC-01, D11-UC-02
EventQueueSQS owned by event platformQueue ARNOpaque DLQ, producer role, consumer role, and event-schema referencesTenant carried inside authorized envelope, not queue IAM identityEncryption, redrive policy, bounded retention, least-privilege send/receive, idempotent consumer requiredDrain or redrive; retain per event policy; delete only after publishers stop and backlog reaches zeroD11-UC-01, D11-UC-02
SecretReferenceSecrets Manager owned by security platformSecret ARN plus version IDOpaque service deployment and rotation referencesNone — credential is service/environment scoped, never customer identityRuntime resolves by task role; value absent from source, Pulumi output, logs, and imageRotate and revoke; destroy retired value after grace period; retain only non-secret audit metadataD11-UC-01
ReleaseTransitionRelease store owned by release controllerrelease_transition_idOpaque v1/v2 digests, ALB weights, probe run, Pulumi update, and actor referencesNone — release is environment-wide; probes contain separately scoped tenant fixturesState machine, separation of approval, exact digests, terminal evidence, and rollback target requiredCandidate, canary, promoted or rolled back; append-only retention through audit policyD11-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.