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.
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.