Shared Infrastructure Stacks in CDK
using SSM Parameter Store as the glue between shared ALBs, VPC Links, and the service stacks that consume them — without CloudFormation's cross-stack reference trap.
- DATE:
- FEB.19.2025
- READ:
- 14 MIN
When your CDK application grows beyond a single stack, the question of how stacks communicate becomes critical. CloudFormation has its own mechanism — Fn::ImportValue with cross-stack exports — but it comes with sharp edges that can halt deployments. This post covers the pattern we use in production: dedicated shared stacks that publish resource identifiers to AWS Systems Manager Parameter Store, consumed by downstream service stacks at synthesis time.
This pattern powers our shared infrastructure layer where a single internal ALB, VPC link, and API Gateway certificate serve multiple ECS Fargate services across QA, Dev, Demo, and Prod stages.
The problem with cross-stack exports
CloudFormation’s Fn::ImportValue looks clean until you try to change anything. Stack A exports a VPC ID; Stack B imports it. This creates a hard dependency: Stack A cannot delete or modify the exported value as long as any other stack imports it.
If you need to refactor the exporting stack — say, restructure the VPC — you’re stuck. CloudFormation refuses to update the export while it’s consumed. You can’t remove the consumer’s reference without first deploying the consumer, which itself depends on the export. This circular trap isn’t hypothetical. We hit it trying to refactor our VPC stack; every downstream stack had to be updated simultaneously, which CloudFormation can’t do in a single deployment.
The SSM Parameter Store pattern
SSM Parameter Store breaks this coupling. The producing stack writes resource identifiers as SSM parameters. The consuming stack reads them at synthesis time via ssm.StringParameter.value_for_string_parameter. There’s no CloudFormation-level dependency between the stacks.
The key technical detail: value_for_string_parameter doesn’t make an API call during cdk synth. It generates a CloudFormation dynamic reference:
# What CDK generates in the template
ListenerArn: '{{resolve:ssm:/livdesign/shared/alb-listener-arn}}'CloudFormation resolves this at deploy time by reading SSM. No network call during synthesis. If the parameter doesn’t exist when CloudFormation runs, the deployment fails with a clear error — which is the right behavior.
The writer side: SharedAlbStack
The shared infrastructure stack creates the load balancer, listener, security group, and VPC link, then stores every identifier that downstream stacks might need:
from aws_cdk import (
Stack,
aws_ec2 as ec2,
aws_elasticloadbalancingv2 as elbv2,
aws_ssm as ssm,
aws_apigatewayv2 as apigwv2,
aws_certificatemanager as acm,
)
from constructs import Construct
class SharedAlbStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# Look up the VPC created by the QA stack
vpc = ec2.Vpc.from_lookup(
self, "SharedVpc",
tags={"aws:cloudformation:stack-name": "BackendStackQA"},
)
alb_sg = ec2.SecurityGroup(
self, "SharedAlbSg",
vpc=vpc,
description="Security group for the shared internal ALB",
allow_all_outbound=True,
)
alb_sg.add_ingress_rule(
ec2.Peer.ipv4(vpc.vpc_cidr_block),
ec2.Port.tcp(443),
"Allow HTTPS from VPC",
)
alb = elbv2.ApplicationLoadBalancer(
self, "SharedAlb",
vpc=vpc,
internet_facing=False,
security_group=alb_sg,
)
listener = alb.add_listener(
"SharedListener",
port=80,
default_action=elbv2.ListenerAction.fixed_response(
status_code=404,
content_type="text/plain",
message_body="No route matched",
),
)
vpc_link = apigwv2.CfnVpcLink(
self, "SharedVpcLink",
name="shared-vpc-link",
subnet_ids=[subnet.subnet_id for subnet in vpc.private_subnets],
security_group_ids=[alb_sg.security_group_id],
)
cert = acm.Certificate(
self, "ApiGatewayCert",
domain_name="api.example.com",
validation=acm.CertificateValidation.from_dns(),
)
# Store everything in SSM — namespaced under /shared/
ssm.StringParameter(self, "VpcLinkIdParam",
parameter_name="/shared/vpc-link-id",
string_value=vpc_link.ref,
description="VPC Link ID for API Gateway HTTP API integration")
ssm.StringParameter(self, "AlbArnParam",
parameter_name="/shared/alb-arn",
string_value=alb.load_balancer_arn,
description="Shared internal ALB ARN")
ssm.StringParameter(self, "AlbListenerArnParam",
parameter_name="/shared/alb-listener-arn",
string_value=listener.listener_arn,
description="Shared ALB listener ARN")
ssm.StringParameter(self, "AlbSgIdParam",
parameter_name="/shared/alb-sg-id",
string_value=alb_sg.security_group_id,
description="Shared ALB security group ID")
ssm.StringParameter(self, "AlbDnsNameParam",
parameter_name="/shared/alb-dns-name",
string_value=alb.load_balancer_dns_name,
description="Shared ALB DNS name for Route 53 alias targets")
ssm.StringParameter(self, "ApiGatewayCertArnParam",
parameter_name="/shared/api-gateway-cert-arn",
string_value=cert.certificate_arn,
description="ACM certificate ARN for API Gateway custom domain")Every SSM parameter has a clear, namespaced path. The /shared/ prefix keeps them grouped and discoverable:
aws ssm get-parameters-by-path
--path "/shared/"
--query "Parameters[].{Name:Name,Value:Value}"
--output tableThe reader side: utils.py
A utility module centralizes all SSM reads so that service stacks never hardcode parameter paths:
from aws_cdk import aws_ssm as ssm
from constructs import Construct
def get_shared_param(scope: Construct, param_id: str, param_name: str) -> str:
return ssm.StringParameter.value_for_string_parameter(scope, param_name)
def get_shared_vpc_link_id(scope: Construct) -> str:
return get_shared_param(scope, "SharedVpcLinkId", "/shared/vpc-link-id")
def get_shared_alb_listener_arn(scope: Construct) -> str:
return get_shared_param(scope, "SharedAlbListenerArn", "/shared/alb-listener-arn")
def get_shared_alb_sg_id(scope: Construct) -> str:
return get_shared_param(scope, "SharedAlbSgId", "/shared/alb-sg-id")
def get_shared_alb_dns_name(scope: Construct) -> str:
return get_shared_param(scope, "SharedAlbDnsName", "/shared/alb-dns-name")A service stack then reads these values without any awareness of where they come from:
from utils import get_shared_alb_listener_arn, get_shared_alb_sg_id
class BackendStackQA(Stack):
def __init__(self, scope, construct_id, **kwargs):
super().__init__(scope, construct_id, **kwargs)
listener_arn = get_shared_alb_listener_arn(self)
alb_sg_id = get_shared_alb_sg_id(self)
shared_alb_sg = ec2.SecurityGroup.from_security_group_id(
self, "SharedAlbSg", alb_sg_id,
)
listener = elbv2.ApplicationListener.from_application_listener_attributes(
self, "SharedListener",
listener_arn=listener_arn,
security_group=shared_alb_sg,
)
listener.add_targets(
"BackendQaTarget",
port=8000,
protocol=elbv2.ApplicationProtocol.HTTP,
targets=[fargate_service.service],
conditions=[
elbv2.ListenerCondition.path_patterns(["/api/qa/*"]),
],
priority=100,
)SSM vs CloudFormation exports
+----------------+--------------------+--------------------+ | Aspect | CF Exports | SSM Parameter | | | | Store | +----------------+--------------------+--------------------+ | Coupling | Hard dependency — | No dependency — | | | exporter can't | producer and | | | change while | consumer are | | | consumers exist | independent | +----------------+--------------------+--------------------+ | Update flow | Must update all | Update parameter, | | | importers before | then redeploy | | | modifying export | consumers at your | | | | pace | +----------------+--------------------+--------------------+ | Circular deps | Common trap with | Impossible — SSM | | | bidirectional | is a flat | | | references | key-value store | +----------------+--------------------+--------------------+ | CLI querying | aws cloudformation | aws ssm | | | list-exports | get-parameters-by- | | | (flat) | path | | | | (hierarchical) | +----------------+--------------------+--------------------+ | Access control | IAM on | Fine-grained IAM | | | CloudFormation | on SSM paths | | | actions | | +----------------+--------------------+--------------------+ | Cross-account | Not supported | Supported via | | | natively | resource policies | +----------------+--------------------+--------------------+ | Encryption | N/A (plaintext in | Optional KMS | | | stack outputs) | encryption | | | | (SecureString) | +----------------+--------------------+--------------------+ | Cost | Free | Free up to 10,000 | | | | standard | | | | parameters | +----------------+--------------------+--------------------+
The STAGE environment variable pattern
A STAGE environment variable controls which stacks are synthesized, keeping deployment times short and preventing accidental production changes:
# app.py
import os
app = App()
stage = os.getenv("STAGE", "").lower()
if stage in ["shared", "all"]:
SharedAlbStack(app, "SharedAlbStack", env=env)
if stage in ["qa", "all"]:
BackendStackQA(app, "BackendStackQA", env=env)
if stage in ["dev", "all"]:
BackendStackDev(app, "BackendStackDev", env=env)
if stage in ["prod", "all"]:
BackendStackProd(app, "BackendStackProd", env=env)
app.synth()Deployment is explicit:
# Deploy only the shared layer first
STAGE=shared cdk deploy --all
# Deploy QA
STAGE=qa cdk deploy --all
# Deploy everything
STAGE=all cdk deploy --all --require-approval broadeningThe dashed dependency between stacks is now a runtime data flow through SSM — not a CloudFormation dependency. Stacks can be deployed independently, in any order, as long as the shared stack has been deployed at least once.
Best practices
Namespace parameters hierarchically. Use /project/layer/resource paths:
/shared/alb-arn # shared infrastructure
/qa/db-endpoint # per-environment config
/prod/api-key # use SecureString for actual secretsThis enables IAM policies scoped to specific paths:
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
"Resource": "arn:aws:ssm:us-east-1:ACCOUNT_ID:parameter/shared/*"
}Use descriptions on every parameter. When someone runs get-parameters-by-path six months from now, the description is the only context they’ll have. The code that wrote it will be long gone from memory.
Avoid SecureString for non-secret values. CloudFormation dynamic references support ssm and ssm-secure modes, but ssm-secure only works with specific resource properties. Use plain String type for resource identifiers like ARNs and IDs.
Guard against missing parameters in CI/CD:
aws ssm get-parameter
--name "/shared/alb-arn"
--query "Parameter.Value"
--output text || {
echo "ERROR: Shared infrastructure not deployed. Run STAGE=shared cdk deploy first."
exit 1
}Add CfnOutput alongside SSM for debugging. While SSM is the primary cross-stack channel, CfnOutput values are still useful for quick lookups in the CloudFormation console:
CfnOutput(self, "AlbDnsName", value=alb.load_balancer_dns_name)The trade-off of this pattern over CloudFormation exports is that the dependency is implicit rather than explicit. CloudFormation won’t automatically deploy the shared stack before the service stack — you need to enforce deployment ordering in CI/CD. But this is worth it. The ability to update shared infrastructure without touching every consumer stack is invaluable as your service count grows.