FeaturedFrom One Giant `pulumi up` to Two: Splitting a Monolithic Stack with StackReference and URN Aliasing
Dipankar Haldar · September 24, 2026 · 10 min read
From One Giant pulumi up to Two: Splitting a Monolithic IaC Stack with StackReference and URN Aliasing
The problem with one big stack
A single Infrastructure-as-Code program that provisions everything — shared platform resources (a database server, secret store, networking, a compute/container environment) and every business-domain application — in one deployment run forces uncomfortable tradeoffs:
- Blast radius: a bad change to one business application risks touching the database server, secret store, or networking it has no business near.
- Deploy cadence: platform resources change rarely; domain applications change constantly. One stack means they share a release cadence and one ever-growing diff/preview.
- Ownership: a platform team can't own core infrastructure independently of feature teams owning their applications without both needing write access to the same state.
This is a common shape for infrastructure that grew organically: one program calling "provision core" followed by "provision every application," back to back, in a single apply.
Target architecture: two stacks, wired by stack outputs
The fix isn't a shared code import between the two halves — it's two independently-deployed stacks, each with its own state file, connected only through stack outputs (a read-only, cross-stack reference mechanism most IaC tools that support remote state offer, e.g. Pulumi's StackReference or Terraform's terraform_remote_state):
- The core stack provisions platform resources (database server, secret store, storage, networking, compute environment, edge/CDN routing) and exports every identifier the other stack needs: a key-vault ID, a database-server ID/FQDN, subnet IDs, a messaging-namespace ID, a monitoring-workspace ID, and so on.
- The component stack never imports the core stack's source code. Instead, its entry point opens a stack reference and reads those outputs directly:
const coreStack = new pulumi.StackReference(`org/infra-core/${environment}`)
const coreOutputs = {
keyVaultId: coreStack.getOutput("keyVaultId"),
databaseServerId: coreStack.getOutput("databaseServerId"),
databaseServerFqdn: coreStack.getOutput("databaseServerFqdn"),
}
new DomainService("orders", { ...baseArgs, ...coreOutputs })
Because a stack-output read returns the same "resolves later" value type a live resource property already has, domain constructors don't change at all. Only the two entry points change, and only in where their values come from: a live resource vs. a resolved remote-state output.
Only one thing is genuinely shared as real code across both stacks: pure, resource-free library code (naming conventions, enums, config, logging helpers). It contains no resources, so duplicating it as a dependency is safe.
The hard part isn't the code — it's the state
Splitting a stack that's never been deployed is trivial. Splitting a stack with hundreds of live cloud resources without a single delete-and-recreate is not. You can't just point two empty stacks at the new programs and apply — the tool would see every resource as brand new.
The real mechanism:
- Export the single stack's state.
- Partition by resource-identifier prefix into two halves — if resource type names/paths already separate cleanly along core vs. business-domain lines, the split is mechanical, not manual.
- Import each half into its own brand-new stack.
- Preview/plan both and require zero diff before touching anything live.
The tricky bit is parent/ownership relationships. Anything that declared itself as a child of a resource that moved to the other stack (an RBAC role assignment, a secret-store entry, a database parented under the old monolithic database server) would otherwise show up as a real delete-and-recreate — because changing the parent changes the resource's stable identity (its URN, or equivalent addressing scheme). The fix is an alias:
new cloud.sql.Database("orders", { /* ... */ }, {
aliases: [{ parent: args.databaseServerUrn }],
})
The alias tells the engine "this resource used to live under this other identity — don't treat the new parent as a different resource." Every call site doing this during the migration got a field added specifically to carry the old ancestor identity across the cutover — deliberately temporary scaffolding, meant to be deleted in one pass once every environment has migrated for real.
Design decisions and hard-won lessons
A few things only became clear once real code and real state were on the table — worth recording as decisions, not just outcomes.
1. Only primitives cross the stack boundary — live objects, class instances, and providers don't.
The first design draft assumed "pass the same shape, just from a different source." That's wrong for anything beyond a plain id/name/uri string. A cloud provider resource, a helper class instance with methods, and a wrapper object that exposes convenience getters all fail to serialize across a remote-state reference — only resolvable primitive values (ids, names, connection strings, URIs) can. Auditing every cross-boundary read up front (grep every place downstream code touches more than .id/.name on a shared object) turns this from an open-ended rewrite into a bounded list of concrete blockers.
2. Resource-scoped helper methods and decorator factories are usually pure functions in disguise.
Two of the biggest apparent blockers — a "grant a role on this resource" helper method, and a "build a diagnostics/logging decorator" factory — turned out, on inspection, to only ever read a provider and a couple of ids from this. Extracting them into plain functions in a shared, resource-free library (taking the ids/provider as explicit arguments instead of closing over a live object) preserves identical behavior while making them safe to call from either stack independently. Look for this pattern before assuming a class-based helper is a hard blocker.
3. Secrets need to be explicitly (re-)wrapped at the export boundary, but "secretness" then propagates automatically. Any output that used to just be a property on a live secret-backed resource has to be intentionally marked as secret when exported from the core stack. Once wrapped, the remote-state reference mechanism preserves that secret marking on the way back into the consuming stack with no extra work — but this only happens if the export site remembers to opt in.
4. Dynamically-keyed value maps can't be exported as a map of individually-resolved values — export one resolved map instead. A map whose keys are only known at runtime (e.g. driven by configuration) can't be expressed as a fixed set of named outputs. The fix is to resolve the entire map to plain values first, then export it as a single output containing that resolved map — the consuming stack re-slices it locally. It's more verbose at every call site (an extra projection step instead of direct property access), but it's the only shape that's both dynamic and boundary-safe.
5. parent/dependsOn relationships cannot cross a stack boundary — full stop.
Anything using another resource as its parent for resource-tree nesting has to drop that option once the two resources live in different stacks/programs; the IaC engine simply doesn't support a cross-stack parent. This is not optional cleanup — it's a hard constraint that forces a URN/identity change on every affected resource, which is exactly why the alias mechanism (see above) exists: to keep that unavoidable identity change from being read as a destructive replace.
6. Convert incrementally, one call site at a time — not by globally swapping the shared argument object. While core and component logic still lived in one process during the transition, only the specific domain being converted had its constructor's argument type narrowed and its values re-projected at that one call site; every other, still-unconverted domain kept receiving the original live-object shape untouched. A global swap of the shared object would have broken every domain that hadn't been converted yet. This per-call-site projection is what let the migration proceed one domain at a time instead of as one big-bang rewrite.
7. Shared "contract" types (the shape of what one stack exports and the other expects) belong in a neutral, resource-free shared library — not inside either stack's own source tree. Importing the output-contract type from the core stack's own package, even as a type-only import that's erased at compile time, quietly recreates the exact coupling the split was meant to remove: it's a source-level dependency from the component side back onto the core side, when the real, intended relationship is "talks only through resolved remote-state values, with zero source dependency" — the same posture already used toward genuinely external/separately-owned infrastructure. Keep contract types shared but ownerless.
8. Before converting a domain, check whether it depends on a shared foundational wrapper used by many domains — convert the wrapper first. The easiest domain to convert reads almost nothing off the shared core object. The next one turned out to construct shared, heavily-reused resource wrapper classes (a storage-account wrapper, a function-app wrapper) that are themselves deeply coupled to the old shared object, and are reused by roughly half of all domains. Converting a domain that depends on one of these first means re-solving the same structural problem per domain; converting the shared wrapper once unblocks every domain that depends on it in a single pass. Audit for this kind of shared, heavily-reused dependency before picking the next conversion target — it changes the size and order of the remaining work substantially.
9. Deploy-order tradeoff: synchronous sequential pipeline vs. fully independent cadences. Because the component stack's remote-state reads are the core stack's already-applied outputs, the core stack must be up to date before the component stack applies — a hard ordering constraint regardless of when each runs. The simpler option (run both stacks' preview/apply back-to-back in the same pipeline job, every time) gives up the "a component-only change shouldn't need to touch core at all" ideal — a core preview still runs on every component change, though it's cheap since nothing in core actually changed — but it removes essentially all of the cross-pipeline coordination complexity (path-scoped triggers, "wait for the other stack" gating, risk of reading stale outputs) that a fully decoupled, independently-triggered version would require. It still delivers the primary payoff (a bad component change literally cannot touch core resources). Treat the fully independent-cadence version as something to revisit only if core's preview time itself becomes the bottleneck — not as a default.
10. Real cutover bugs surfaced things no local test or mock could catch. A migration-script filter that excluded an entire resource family from the state export produced over a hundred spurious "create" entries in the first real preview — invisible in local dry runs against smaller/different sample data, only caught once run against the real, full-sized state. Separately, one resource type failed hard on refresh when its underlying principal no longer existed live, while a structurally similar resource type degraded gracefully in the same situation — a difference in error-handling behavior between resource types that only surfaces once real, slightly-decayed production state is refreshed for the first time. Neither of these is a design flaw in the split itself; both are exactly the class of bug that only a real, full-scale dry run (and eventually a real cutover) can find. Budget for this category of bug explicitly rather than assuming a clean local test suite means the real cutover will be too.
11. Temporary migration scaffolding must be removed in one atomic pass, never piecemeal, and only after every environment has migrated. The aliases, the migration script, and any pipeline flag gating the cutover all exist solely to bridge already-deployed resources across the boundary exactly once per environment. Removing any of them before every environment has completed its own real migration would cause the next environment's migration to see spurious diffs against scaffolding that's already gone. Track removal as a single, explicitly-scheduled follow-up change — not cleanup to do "whenever," and not something to chip away at per-environment.
Verify twice before it's real
Before touching real remote state, the whole split should be dry-run locally: export the real state, split it offline, import both halves into throwaway local-backend stacks, and preview each with real (read-only) cloud credentials — never apply, ever, in the dry run. A dry run like this can catch bugs in the verification harness itself (e.g. a wrapper-vs-underlying-resource identity mismatch that would show fake creates) before they ever touch a real stack.
Then it should run for real, once per environment, starting with the smallest/lowest-risk one: split into a core stack and a component stack, apply the core stack first (expect only a handful of harmless drift-only changes — this also confirms the imported state matches the new program), then preview the component stack. Expect the first real preview to surface at least one migration-script bug (e.g. a filter that accidentally excludes a whole resource family from the state export, showing up as spurious creates) — fix it, backfill state if needed, and re-verify down to only pre-existing, split-unrelated drift. Only once a run converges to zero unexpected diff across a few consecutive apply cycles should the "component" side's apply gate be opened for real traffic.
What this buys you
- Blast radius, for real: a bad component-stack change literally cannot touch the database server or secret store — they're not in that program.
- Independent ownership: a platform team can own the core stack's state without granting write access to every domain team's resources.
- A repeatable playbook: the alias/identity mechanism generalizes — the same stack-reference pattern extends cleanly to further per-domain stacks later, with no additional restructuring needed.
The scaffolding (aliases, the migration script, any pipeline feature-flag gating the cutover) is explicitly temporary — it exists only to carry already-deployed resources across the boundary once per environment. Plan to delete all of it in a single follow-up pass once every environment has migrated for real, re-verified with a clean preview on both stacks.
If you're staring down the same monolithic-stack problem: don't skip the local dry run. Bugs caught there are cheap to fix against a throwaway local-backend stack — and a very different conversation against live remote state.
Comments (0)
Sign in to leave a comment.- No comments yet. Be the first to share your thoughts!