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

ApproachVersion controlledSupports secretsChange without redeployShared across stacks
Static config (cdk.json)YesNoNoNo
Dynamic configNoPartialYesNo
Secrets ManagerNoYesYesYes
CI/CD context injectionNoNoYesNo

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

  1. These approaches combine well - SSM for non-sensitive shared config, Secrets Manager for secrets, CI/CD context injection for environment identifiers.
  2. Secrets Manager values are never embedded in CloudFormation templates - they are resolved at runtime by the Lambda itself.
  3. On Windows with PowerShell, set the env var with $env:ENV="dev"; cdk deploy instead of the bash syntax.