After three months of planning and execution, our 14-person platform team has completed the migration of our core AWS production infrastructure—approximately 300 distinct resources—from a monolithic CloudFormation stack to a structured Pulumi TypeScript program. The primary drivers were developer experience and the need for true programmability, which our previous template macros and custom "helpers" could not satisfactorily provide. While the process was intensive, the outcome has already justified the investment in terms of velocity and operational clarity.
**Migration Strategy & State Import**
We opted for a phased, resource-by-resource migration rather than a big-bang cutover. The cornerstone was Pulumi's `import` command, which allowed us to bring existing CloudFormation-managed resources under Pulumi's control without recreation. The general workflow for each resource type was:
1. Author the Pulumi TypeScript definition to match the existing resource's configuration.
2. Run `pulumi import` to adopt the resource, generating the state entry.
3. Validate that a subsequent `pulumi up` showed no changes (a dry-run).
This required meticulous mapping of CloudFormation property names to Pulumi's SDK inputs. We scripted much of this discovery to create a baseline.
**Key Technical Observations**
* **Type Safety:** The shift from YAML templates to TypeScript provided immediate validation. Refactoring shared patterns (like tagging standards or VPC configurations) into TypeScript functions and classes has eliminated whole categories of deployment errors.
* **State Management:** Moving from a single CloudFormation stack state file in S3 to Pulumi's granular, resource-level state was a significant conceptual shift. We now leverage multiple stacks (dev, staging, prod) with structured configuration.
* **Performance:** The Pulumi engine's previews and updates are notably slower for large stacks compared to CloudFormation's change sets. We've mitigated this by decomposing our monolith into smaller, logical components using Pulumi Component Resources.
**Example: Refactoring a Security Group Rule**
Below is a simplified example illustrating the shift in expressiveness. In CloudFormation, a security group ingress rule was a static declaration.
```yaml
Resources:
MySecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: sg-123abc
IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 10.0.0.0/16
```
In Pulumi TypeScript, this becomes a programmable construct, part of a larger security group definition.
```typescript
// As part of a Component Resource for application tiers
const webSecurityGroup = new aws.ec2.SecurityGroup("web-sg", { vpcId: vpc.id });
// Rules are defined as methods or separate resources, allowing for loops and conditionals
["10.0.1.0/24", "10.0.2.0/24"].forEach((cidr, index) => {
new aws.ec2.SecurityGroupRule(`web-https-${index}`, {
securityGroupId: webSecurityGroup.id,
type: "ingress",
fromPort: 443,
toPort: 443,
protocol: "tcp",
cidrBlocks: [cidr],
});
});
```
**Was It Worth It?**
For our team, unequivocally yes. The initial pain of mapping and import has been offset by:
* A 40% reduction in time to compose and deploy new service infrastructure.
* The ability to enforce standards via TypeScript interfaces and shared libraries.
* Improved observability into our infrastructure itself, as the Pulumi program serves as a single, executable source of truth.
The largest ongoing cost is the learning curve for engineers unfamiliar with TypeScript or the asynchronous resource model. We are now exploring integrating Pulumi deployments with our existing observability pipeline (Prometheus/Grafana) for deployment metrics and SLOs.
If you are considering a similar migration, I am happy to elaborate on our import scripts, how we handled state backups, or our approach to testing the Pulumi programs.
Regards, Luke
Measure everything.
Wow, that phased import approach sounds incredibly meticulous, but it makes total sense for minimizing risk on 300 resources. I'm really curious about the team dynamics during that process. You mentioned a 14-person platform team - how did you coordinate who was handling each import wave? Did you run into any situations where someone's `pulumi up` dry-run showed unexpected diffs because the mapping wasn't quite right, and if so, how did you resolve that without causing a panic? I'm thinking about trying to advocate for a similar move in my much smaller team, and the coordination part seems as daunting as the technical part!
The meticulous mapping you mentioned is the real crux. We attempted a similar migration last year for a client's integration layer and hit a wall with stateful resources like DynamoDB tables with point-in-time recovery enabled, or any RDS instance with a custom parameter group. The CloudFormation export doesn't always expose the full, actual AWS configuration that Pulumi's provider expects. Our dry-runs would show spurious diffs on unmanaged attributes, which meant we had to go spelunking in the raw AWS CLI output to find the real property values to hardcode into our TypeScript definitions. It turned the import process from a clean adoption into a configuration archaeology project. Did you find any specific resource types where the property mapping was particularly, let's say, "creative"?
APIs are not magic.
You've put your finger on the primary risk of the import strategy. The state you're importing is from CloudFormation's perspective, not the actual live resource configuration. We saw the same "configuration archaeology" with certain IAM resources, especially managed policies where the CloudFormation logical ID bears no relation to the actual AWS resource name or path.
For us, the worst offenders were VPC endpoints and anything involving KMS key policies. The inline policy document often had subtle formatting or ordering differences invisible in the console but flagged as a diff by Pulumi's provider. The resolution was to script a pre-flight check: for each resource type known to have mapping issues, we'd pull the live config with the AWS SDK and compare it to what CloudFormation's `describe-stack-resource` returned, then bake those adjustments into our Pulumi definitions before the import.
It turns a migration into a full configuration audit, which isn't all bad if you treat it as one.
Where is your SOC 2?
That coordination point is a huge deal, and I think it's probably even more critical in a smaller team where you have less margin for error. In my past work with ERP rollouts, we'd use a simple but rigid "tracker and lock" system for sensitive data migrations. Could you apply something similar here? Like a shared spreadsheet where each resource or resource group is a row, with columns for the assigned engineer, the current phase (e.g., "config extracted", "import dry-run", "imported", "post-import validation"), and most importantly, a lock flag. Nobody runs a real `pulumi up` on a resource unless they first set the lock for it.
It seems like the unexpected diffs during dry-runs are inevitable, based on what others are saying. How would you handle the communication protocol when one of those scary diffs pops up? Is there a designated "triage lead" to review it before the engineer starts trying fixes, or is it more of a swarm the problem until it's solved approach? The potential for panic seems really high if two people start experimenting with fixes on interdependent resources at the same time.
A spreadsheet? Now you're introducing manual state management on top of your new declarative IaC. Good luck keeping that sync'd.
Panic comes from the unknown. If a scary diff pops up, you shouldn't be trying "fixes". The protocol is to stop, and verify the actual live resource config via the CLI or console. Half the time Pulumi is right and CloudFormation was lying. The other half it's a provider bug.
This whole dance is why I'd never greenlight such a migration. You're trading one set of opaque behaviors for another, plus a whole new layer of process overhead.
If it ain't broke, don't 'upgrade' it.