| title | Adopt Hulumi inside an existing Pulumi project |
|---|---|
| description | Mid-stack adoption path — replace hand-rolled AWS resources with Hulumi components without forcing destroy/recreate. |
You already have a Pulumi project and want to replace existing hand-rolled aws.s3.Bucket / aws.s3.BucketV2 / aws.cloudtrail.Trail / aws.guardduty.Detector / etc. resources with Hulumi components — without forcing every resource through a destroy/recreate cycle.
This is the mid-stack adoption path. For a fresh project, see account-bootstrap.md. For a Terraform-to-Pulumi migration, see migration-from-terraform.md.
- An existing Pulumi project at
@pulumi/pulumi >= 3.232.0. - An understanding of which resources are stateful (buckets, RDS, KMS keys) vs ephemeral (Lambda, IAM roles).
- An IaC role tagged
hulumi:iac-role=truealready in use, or willingness to add the tag.
When you replace a hand-rolled aws.s3.Bucket or aws.s3.BucketV2 with a Hulumi SecureBucket, the URN changes:
- Before (current token):
urn:pulumi:dev::myproject::aws:s3/bucket:Bucket::audit-logs - Before (legacy V2 token):
urn:pulumi:dev::myproject::aws:s3/bucketV2:BucketV2::audit-logs - After:
urn:pulumi:dev::myproject::hulumi:baseline:aws:SecureBucket$aws:s3/bucket:Bucket::audit-logs-bucket
Without an alias, Pulumi treats this as destroy-old + create-new. With aliases, the new resource adopts the old URN — the bucket's data, encryption, and policies are unchanged.
import { SecureBucket } from "@hulumi/baseline/aws";
const audit = new SecureBucket(
"audit-logs",
{
tier: "startup-hardened",
bucketName: "my-org-audit-logs",
},
{
aliases: [
// The old hand-rolled bucket token — replace with whatever your stack
// had previously: aws:s3/bucket:Bucket or aws:s3/bucketV2:BucketV2.
{ type: "aws:s3/bucket:Bucket", name: "audit-logs" },
{ type: "aws:s3/bucketV2:BucketV2", name: "audit-logs" },
],
},
);Run pulumi preview and confirm the diff is ~ (update), not + / -.
SecureBucket's built-in child aliases cover its own V2-to-non-V2 migration. Mid-stack adoption is a separate parent/name migration, so keep the project-specific aliases until preview is clean.
pulumi stack export | jq '.deployment.resources[] | select(.type | startswith("aws:")) | {urn, type}' > to-migrate.jsonFor each resource type, decide which Hulumi component it maps to. Typical mappings:
| Hand-rolled type | Hulumi target |
|---|---|
aws:s3/bucket:Bucket / aws:s3/bucketV2:BucketV2 + child resources |
@hulumi/baseline.aws.SecureBucket |
aws:cloudtrail/trail:Trail + log group + bucket |
@hulumi/baseline.aws.AccountFoundation (CloudTrail sub-component) |
aws:guardduty/detector:Detector + features |
@hulumi/baseline.aws.AccountFoundation (GuardDuty sub-component) |
aws:iam/passwordPolicy:PasswordPolicy |
@hulumi/baseline.aws.AccountFoundation (IAM baseline) |
aws:kms/key:Key |
@hulumi/baseline.aws.AccountFoundation (KMS ring) |
The Hulumi components are intentionally additive — adopting AccountFoundation doesn't require ripping out unrelated resources.
For each replacement, capture the OLD URN before deleting the old resource. The pattern:
// Before — hand-rolled
const oldBucket = new aws.s3.Bucket("audit-logs", {
bucket: "my-org-audit-logs",
});
// ... and 4-5 child resources for encryption / versioning / etc.
// After — Hulumi
const audit = new SecureBucket(
"audit-logs",
{ tier: "startup-hardened", bucketName: "my-org-audit-logs" },
{
aliases: [
{ type: "aws:s3/bucket:Bucket", name: "audit-logs" },
{ type: "aws:s3/bucketV2:BucketV2", name: "audit-logs" },
// The child resources also need aliases if Hulumi's child shape
// matches one-for-one. SecureBucket's children are URN-stable
// after the built-in V2-to-non-V2 migration, but adopting old
// hand-rolled children still needs project-specific preview review.
],
},
);For AccountFoundation, the alias surface is wider — every sub-component (CloudTrail, Config, GuardDuty, SecurityHub, IAM, KMS) has its own URN. The component's reference doc lists the canonical aliases.
Hulumi components composed with hand-rolled resources may need explicit ordering hints:
const baseline = new AccountFoundation("baseline", {
tier: "startup-hardened",
iacRoleArn,
region,
});
const myService = new MyExistingComponent(
"my-service",
{
/* ... */
},
{ dependsOn: [baseline] },
);The dependsOn is the documented workaround for the pulumi.dynamic.Resource + vitest-pool gotcha (see FAQ) and is also the right shape for "wait for the baseline before applying my service-specific resources."
Apply replacements one at a time. After each:
pulumi preview --target 'urn:pulumi:dev::myproject::hulumi:aws:SecureBucket::audit-logs'Expected diff: ~ (update) for every previously-hand-rolled child. If you see + (create) for a resource that should already exist, the alias didn't fire — review the alias type/name pair.
pulumi up --target 'urn:pulumi:dev::myproject::hulumi:aws:SecureBucket::audit-logs'Surgical applies are safer than pulumi up --everything during a migration. After each apply, run drift detection (drift-detection.md) to confirm the resource is cleanly Hulumi-managed.
The aliases are migration-only scaffolding. Once pulumi up is a no-op, remove them:
// Final form
const audit = new SecureBucket("audit-logs", {
tier: "startup-hardened",
bucketName: "my-org-audit-logs",
});Run pulumi preview once more — should be a no-op.
While the migration is in flight:
- The drift classifier may report
Unknown / lowfor in-flight resources because git-log authorship is split between hand-rolled and Hulumi-composed. - Provider-version checks are unaffected —
@pulumi/awsis exact-pinned by Hulumi.
After all replacements + alias removal, drift classifier results should stabilize. If a previously-hand-rolled resource still reports ConsoleBreakGlass / high post-migration, check that the resource's hulumi:iac-role tag is set — the classifier reads tags to identify IaC-managed resources.
Per replacement step:
- Before
pulumi up: revert the source code to the hand-rolled shape; the nextpulumi previewshows no change. - After
pulumi upbut before alias removal: re-add the hand-rolled code with the SAME alias structure (in reverse). Pulumi sees the alias and re-adopts the URN; no resource churn. - After alias removal: rollback requires manual
pulumi statesurgery. Don't remove aliases until you're confident the migration is sticky.
The window of free rollback closes when you delete the aliases. Be deliberate.
- Alias type/name mismatch. The
typefield uses Pulumi's resource-type string (e.g.aws:s3/bucket:Bucketoraws:s3/bucketV2:BucketV2), not the JSON path. Get it wrong and the alias silently doesn't fire — the migration becomes a destroy/recreate. - Confusing package migration with mid-stack adoption. SecureBucket includes aliases for its own V2-to-non-V2 child type rename. Replacing old hand-rolled resources still changes parent/name shape and may need additional project-specific aliases.
- Tag drift mid-migration. If the IaC role's
hulumi:iac-role=truetag isn't on every resource being adopted, the SCP (if applied) or H3 policy will fire. Add the tag pre-migration or fold it into the migration commit.
- Wire drift detection into CI.
- Adopt the policy pack once the components are landed.
- Verify SLSA provenance on installed
@hulumi/*packages.
Tracking issue: #34.