This is Chapter 2 of the Infrastructure as Code with AWS CDK series. Four approaches to environment-specific configuration in CDK stacks - same use case throughout so the trade-offs sit side by side.
Use case: An S3 bucket and Lambda function where bucket name, log level, and Lambda timeout vary per environment (dev, staging, prod).
Quick comparison
| Approach | Version controlled | Supports secrets | Change without redeploy | Shared across stacks |
|---|---|---|---|---|
Static config (cdk.json) | Yes | No | No | No |
| Dynamic config | No | Partial | Yes | No |
| Secrets Manager | No | Yes | Yes | Yes |
| CI/CD context injection | No | No | Yes | No |
Local dev patterns
Suitable for personal projects or local development. Neither is a good fit for CI/CD pipelines or shared team environments.
Static config (cdk.json)
Store environment-specific config in cdk.json under the context key. CDK reads this file automatically at synth time.
When to use: Stable, non-sensitive values that are the same for everyone on the team.
{
"app": "python3 app.py",
"context": {
"dev": {
"bucket_name": "my-bucket-dev",
"log_level": "DEBUG",
"lambda_timeout": 30
},
"prod": {
"bucket_name": "my-bucket-prod",
"log_level": "INFO",
"lambda_timeout": 60
}
}
}
Read context in app.py:
app = cdk.App()
env_name = app.node.try_get_context("env") or "dev"
config = app.node.try_get_context(env_name)
MyStack(app, f"MyStack-{env_name}", config=config)
Deploy with:
cdk deploy -c env=dev
cdk deploy -c env=prod
Trade-off: Any config change requires a commit and redeploy. Never put secrets here - cdk.json is committed to source control.
Dynamic config
Load config at synth time from a local file or environment variables, outside the CDK project. Useful when values differ per developer or machine.
When to use: Values that differ between developers - e.g. personal AWS account IDs, local endpoint overrides.
Create a config.dev.json outside source control:
{
"bucket_name": "my-bucket-dev",
"log_level": "DEBUG",
"lambda_timeout": 30
}
Load it in app.py:
import json
import os
app = cdk.App()
env_name = os.environ.get("ENV", "dev")
with open(f"config.{env_name}.json") as f:
config = json.load(f)
MyStack(app, f"MyStack-{env_name}", config=config)
Add config files to .gitignore:
config.*.json
Deploy with:
ENV=dev cdk deploy
ENV=prod cdk deploy
Trade-off: Config lives outside source control - harder to audit and reproduce. Not suitable for team environments where config needs to be consistent.
Production patterns
Config lives in AWS or is injected by the pipeline - not in the codebase. Values can change without a code commit.
Secrets Manager
Store sensitive config in Secrets Manager. Values are fetched at deploy time and injected into the Lambda environment - they are never visible in the CloudFormation template.
When to use: API keys, database passwords, tokens. Secrets should never go in cdk.json or plain environment variables.
Create a secret (run once per environment):
aws secretsmanager create-secret \
--name "/my-app/dev/config" \
--secret-string '{"api_key":"abc123","db_password":"s3cr3t"}'
Reference the secret in the stack:
from aws_cdk import aws_secretsmanager as secretsmanager
class MyStack(Stack):
def __init__(self, scope, id, env_name, **kwargs):
super().__init__(scope, id, **kwargs)
secret = secretsmanager.Secret.from_secret_name_v2(
self, "AppSecret", f"/my-app/{env_name}/config"
)
fn = _lambda.Function(self, "MyFunction",
...
environment={
"SECRET_ARN": secret.secret_arn
}
)
secret.grant_read(fn)
The Lambda reads the secret at runtime using the ARN:
import boto3, json, os
def handler(event, context):
client = boto3.client("secretsmanager")
secret = json.loads(client.get_secret_value(
SecretId=os.environ["SECRET_ARN"]
)["SecretString"])
api_key = secret["api_key"]
Trade-off: Cost per secret per month. Values are only available at runtime, not synth time. Rotation adds operational overhead but is worth setting up for credentials.
For non-sensitive shared config that needs to change without a code commit - bucket names, log levels, timeouts - SSM Parameter Store is an alternative. Parameters are fetched at synth time via value_from_lookup and cached in cdk.context.json:
from aws_cdk import aws_ssm as ssm
bucket_name = ssm.StringParameter.value_from_lookup(
self, f"/my-app/{env_name}/bucket_name"
)
Commit cdk.context.json so CI doesn’t need live SSM access on every run. SSM has no secrets support - keep anything sensitive in Secrets Manager.
CI/CD context injection
Pass values into the stack directly from the pipeline via CDK context flags. The stack reads them at synth time - no external config files or AWS lookups required.
When to use: Values that are known to the pipeline at deploy time - environment name, account ID, deploy target region, feature flags. Works well when the pipeline is the source of truth for what gets deployed where.
The stack reads context with try_get_context and uses Stack.of(self).account for the resolved AWS account:
from aws_cdk import Stack
from constructs import Construct
class MyStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
env_name = self.node.try_get_context("env")
profile = self.node.try_get_context("profile")
account_id = Stack.of(self).account
bucket = s3.Bucket(self, "FilesBucket",
bucket_name=f"my-bucket-{env_name}-{account_id}"
)
The pipeline passes context at deploy time:
# GitHub Actions, CodePipeline, or any CI runner
cdk deploy -c env=prod -c profile=my-prod
A GitHub Actions step looks like:
- name: Deploy
run: cdk deploy --require-approval never -c env=prod -c profile=my-prod
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
try_get_context returns None if the key is not passed. Add a guard if the value is required:
env_name = self.node.try_get_context("env")
if not env_name:
raise ValueError("env context is required - pass with -c env=<name>")
Trade-off: Context values are visible in the pipeline logs and the synthesized CloudFormation template. Keep secrets in Secrets Manager and pass only non-sensitive identifiers this way.
Notes
- These approaches combine well - SSM for non-sensitive shared config, Secrets Manager for secrets, CI/CD context injection for environment identifiers.
- Secrets Manager values are never embedded in CloudFormation templates - they are resolved at runtime by the Lambda itself.
- On Windows with PowerShell, set the env var with
$env:ENV="dev"; cdk deployinstead of the bash syntax.