Building Multi-Environment Configurations with AWS CDK and TypeScript

Written by Praveen Kumar Patidar.

The AWS Cloud Development Kit (CDK) is an open-source software development framework that lets you define your cloud application resources using familiar programming languages. With CDK, you can write your infrastructure as code using various languages. 

By using AWS CDK, developers can use high-level constructs to create and provision AWS resources. This guide will focus on using TypeScript to manage multi-environment configurations effectively. Also,  you’ll have a fully functioning AWS CDK project capable of managing different environments efficiently.

Multi Environment Setup Challenges

A multi-environment setup is crucial in modern software development as it provides isolation for different stages of the development lifecycle, such as development, testing, staging, and production. This isolation reduces the risk of bugs reaching production, enables controlled deployment and rollback, and allows better resource management customised for each environment’s needs. It also supports compliance and security requirements and facilitates parallel development and testing by different teams.

AWS CDK offers a powerful and flexible way to define cloud infrastructure by enabling developers to create modular, reusable, and highly customisable cloud architectures. However, setting up multi-environment configurations in CDK can be challenging compared to tools like Terraform or CloudFormation. These challenges include managing dependencies and configuration files, ensuring consistent validation across environments, and sometimes dealing with the abstraction layer that can obscure underlying CloudFormation details. In this blog, we’ve addressed these challenges by implementing best practices for code organisation, utilising robust validation tools like Zod, and gaining a deeper understanding of CloudFormation, demonstrating how to effectively manage multi-environment setups in AWS CDK.

This guide will show you how to create a robust multi-environment configuration system using YAML for configuration files, validate them using Zod, and deploy your AWS resources using CDK.

Setting Up the CDK Project

If your AWS CDK project is not yet set up, follow these steps to create a basic cdk project. We’ll start by setting up a new TypeScript project for AWS CDK. First, ensure that AWS CDK is installed globally:

# Install CDK Globally 
npm install -g aws-cdk

# Create Directory 
mkdir demo-cdk
cd demo-cdk

# Initialize the new CDK Project with typescript
cdk init app --language typescript

These commands will create a new directory with a basic CDK setup, including an initial stack and the necessary configurations. For now, leave this project aside and let's visit the configuration project. 

Setting up multi-environment configuration Typescript Project

Multiple projects may require shared configurations, such as tag naming conventions when using a multi-environment configuration. To address this, I am creating a separate TypeScript project in a parallel directory to ensure that the main project ( in this case demo-cdk) is less affected. This will allow the configurations to be reusable across all projects, whether they need a multi-environment setup within the same demo-cdk project or outside of it.

Create a common-config typescript project, parallel to demo-cdk project. And initialise the typescript project. 

mkdir common-config
cd common-config
npm init -y
npm install --save-dev typescript @types/node
npm install console fs load-yaml-file yaml zod zod-to-json-schema
npm install --save-dev @types/js-yaml @types/node

You have now installed the basic packages. Here is the directory structure you need to create that is referred to in the blog and the definition of it. 

.
# YAML config files for common and env specific config
./configs 
   	./test.yaml
   	./dev.yaml
   	./common.yaml
# Zod Schema Definition and utility script
./lib
 	./schema.ts
 	./loadConfig.ts
# this will be generated dynamically. its here for reference.
./schemas     
./build-schema.json
 	./common-schema.json
# Test script to test the setup. 
./test
 	./test.ts
# To import the const for external use. 
./index.ts

Let's start with sample configuration schema definition -  /lib/schema.ts

import { z } from 'zod';

// CommonConfig
export const commonSchema = z.object({
    App: z.string(),
    Project: z.string(),
    AWSRegion: z
        .literal('ap-southeast-2')
        .describe('Only ap-southeast-2 Allowed'),
}).strict();

// Define the schema for EKS
const eksSchema = z.object({
    EKSVersion: z.string(),
    CoreNodeCount: z.number(),
}).strict();

// Define the schema for Networking
const networkingSchema = z.object({
    VPCCidr: z.string(),
    EKSTags: z.boolean(),
    MaxAzs: z.literal(2).or(z.literal(3)),
}).strict();

// Define the main schema
export const buildSchema = z.object({
    AWSAccountID: z.string(),
    AWSProfileName: z.string(),
    Environment: z.string(),
    Eks: eksSchema,
    Networking: networkingSchema,
}).strict();


export type BuildSchemaType = z.infer<typeof buildSchema>;
export type CommonSchemaType = z.infer<typeof commonSchema>;

You can see how we set up a common config (for all projects) and build config (per environment) to maintain the configuration. The schema has strict rules not to allow any other values in the config files. If you don't use "strict," you can add additional properties, but they won't be considered in the schema objects. 

Let now create a script to utilise the schema -  /lib/loadConfig.ts

import * as fs from 'fs';
import * as yaml from 'js-yaml';
import * as z from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { buildSchema, commonSchema } from './schema';
import * as path from 'path';

function resolvePath(relativePath: string): string {
    const configPath = path.resolve(__dirname, relativePath)
    // Ensure the directory exists before writing the file
    const directory = path.dirname(configPath);
    if (!fs.existsSync(directory)) {
        fs.mkdirSync(directory, { recursive: true });
        console.log(`Directory created: ${configPath}`);
    }
    return configPath;
}

// Build Schema On everytime the Synth Called
const buildSchemaFiles = (env: string, schema: z.Schema) => {
    const jsonSchema = zodToJsonSchema(schema);
    if (env == "common") {
        fs.writeFileSync(resolvePath('../schemas/common-schema.json'), JSON.stringify(jsonSchema, null, 2), 'utf-8');
    } else {
        fs.writeFileSync(resolvePath('../schemas/build-schema.json'), JSON.stringify(jsonSchema, null, 2), 'utf-8');
    }
    console.log(`JSON Schema Generation Completed for ${env}`);
}

// Function to load and parse the YAML file
const loadYamlFile = (filePath: string) => {
    const fileContents = fs.readFileSync(filePath, 'utf8');
    return yaml.load(fileContents);
};

// Function to validate the parsed YAML data against the Zod schema
const validateYamlData = (data: any, schema: z.Schema) => {
    try {
        return schema.parse(data);
    } catch (error) {
        if (error instanceof z.ZodError) {
            console.error('Schema validation errors:', error.errors);
            throw error; // Rethrow to indicate validation failure
        } else {
            console.error('Unexpected error:', error);
            throw error; // Rethrow unexpected errors
        }
    }
};

// Main function to load, parse, and validate the YAML file
const loadConfig = (env: string, schema: z.Schema) => {
    try {
        buildSchemaFiles(env, schema);
        const data = loadYamlFile(resolvePath(`../configs/${env}.yaml`));
        const validatedData = validateYamlData(data, schema);
        console.log('YAML validation successful:', validatedData);
        return validatedData;
    } catch (error) {
        console.error('Failed to validate YAML file');
        console.error(error)
        process.exit(1); // Exit with failure code
    }
};
export { loadConfig }

/lib/loadConfig.ts will ensure the generation of the schema JSONs for build and common config, along with the validation of the YAML files.

Now let's move to another exciting feature to validate the yaml files when typing also it will auto-suggest allowed values. Make sure to enable the YAML Plugin in the VSCode.

configs/common.yaml

# yaml-language-server: $schema=../schemas/common-schema.json
---
App: "eks-cdk-demo"
Project: "demo-digital"
AWSRegion: "ap-southeast-2"

configs/dev.yaml

# yaml-language-server: $schema=../schemas/build-schema.json
---
AWSAccountID: "123456789013"
Environment: "dev"
Eks:
  EKSVersion: "1.29"
  CoreNodeCount: 1
Networking:
  VPCCidr: 10.0.0.0/20
  EKSTags: true
  MaxAzs: 2

configs/test.yaml

# yaml-language-server: $schema=../schemas/build-schema.json
---
AWSAccountID: "123456789013"
Environment: "test"
Eks:
  EKSVersion: "1.29"
  CoreNodeCount: 1
Networking:
  VPCCidr: 10.0.0.0/20
  EKSTags: true
  MaxAzs: 3

By defining the yaml-language-server options we are now enabling schema validation and error when typing the yaml. Below are some screenshots that guide how only allowed values are checked and additional properties are validated.

MaxAzs Allowed values 

Error if the region is not ap-southeast-2


Additional Setup for ease of use

test/test.ts This will ensure the generation and validation as part of the test case. To run the test just run with the command - npx ts-node test/test.ts

import { BuildSchemaType, CommonSchemaType, buildSchema, commonSchema } from '../lib/schema';
import { loadConfig } from '../lib/loadConfig';

const commonConfig: CommonSchemaType = loadConfig("common", commonSchema);
const devbuildConfig: BuildSchemaType = loadConfig("dev", buildSchema);
const testbuildConfig: BuildSchemaType = loadConfig("test", buildSchema);

console.log(commonConfig)
console.log(devbuildConfig)
console.log(testbuildConfig)

index.ts This will ensure all the variables are exposed from the common-config project. This will also help package the project and use a package manager to import it back into CDK projects. 

export { BuildSchemaType, CommonSchemaType, buildSchema, commonSchema } from './lib/schema';
export { loadConfig } from './lib/loadConfig';

package.json

{
  "name": "config",
  "version": "0.1.0",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "npx ts-node test/test.ts"
  },
  "dependencies": {
    "console": "^0.7.2",
    "fs": "^0.0.1-security",
    "load-yaml-file": "^1.0.0",
    "yaml": "^2.4.2",
    "zod": "^3.23.8",
    "zod-to-json-schema": "^3.23.1"
  },
  "devDependencies": {
    "@types/js-yaml": "^4.0.9",
    "@types/node": "^20.14.9"
  }
}

Integrate the CDK code with the Config Generator Project

Now, you can either have a separate CDK project as it's going to be in the blog, or you can have the above files in the actual CDK project. In my demo-cdk project, I have set up bin/cdk.ts to initiate the CDK project and lib/vpc-stack.ts to create the VPC stack.

bin/cdk.ts

!/usr/bin/env node
import { loadConfig } from '../../common-config';
import { BuildSchemaType, CommonSchemaType, buildSchema, commonSchema } from '../../common-config';
import * as cdk from 'aws-cdk-lib';
import { VpcStack } from '../lib/vpc-stack';

const app = new cdk.App();
const envName = app.node.tryGetContext('envName')
// Check if the variable is provided
if (!envName) {
    throw new Error(`Could not find environment Variable envName`);
}
// validate load and assign the config variables
const envConfig: BuildSchemaType = loadConfig(envName, buildSchema);
const commonConfig: CommonSchemaType = loadConfig("common", commonSchema);

let stackName = commonConfig.App + "-" + envName + "-vpc";

const vpcStack = new VpcStack(app, stackName, envConfig, commonConfig,
    {
        env:
        {
            region: commonConfig.AWSRegion,
            account: envConfig.AWSAccountID
        }
    });

The cdk.ts first ensures that the context variable is passed from command line names envName.

Once the variable is in, use the common-config functions to generate and validate the schema and assign it to envConfig and commonConfig variables.

Here is the command to pass the envName as a context variable -

cdk synth --c envName=dev

Here is the sample output for the invalid values

issues: [
    {
      received: 'ap-southeast-3',
      code: 'invalid_literal',
      expected: 'ap-southeast-2',
      path: [Array],
      message: 'Invalid literal value, expected "ap-southeast-2"'
    }
  ],
  addIssue: [Function (anonymous)],
  addIssues: [Function (anonymous)],
  errors: [
    {
      received: 'ap-southeast-3',
      code: 'invalid_literal',
      expected: 'ap-southeast-2',
      path: [Array],
      message: 'Invalid literal value, expected "ap-southeast-2"'
    }
  ]
}

Subprocess exited with error 1

For reference here is lib/vpc-stack.ts

import { BuildSchemaType, CommonSchemaType } from '../../common-config/lib/schema';
import { Stack, StackProps, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';


export class VpcStack extends Stack {
  constructor(scope: Construct, id: string, buildConfig: BuildSchemaType, commonConfig: CommonSchemaType, props?: StackProps,) {
    super(scope, id, props);
    const vpc = new ec2.Vpc(this, 'vpc', {
      maxAzs: buildConfig.Networking.MaxAzs,
      ipAddresses: ec2.IpAddresses.cidr(buildConfig.Networking.VPCCidr),
      vpcName: commonConfig.App + "-" + buildConfig.Environment + "-vpc",
      subnetConfiguration: [
        {
          cidrMask: 23,
          name: 'public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 23,
          name: 'private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        }
      ]
    })

    // Tagging all subnetfor EKSKSTags
    if (buildConfig.Networking.EKSTags) {
      for (const subnet of vpc.publicSubnets) {
        Tags.of(subnet).add(
          "kubernetes.io/role/elb",
          "1",
        );
      }
      for (const subnet of vpc.privateSubnets) {
        Tags.of(subnet).add(
          "kubernetes.io/role/internal-elb",
          "1",
        );
      }
    }
  }
}

Conclusion

In wrapping up this blog, we've explored the intricacies of setting up a robust multi-environment configuration system with AWS CDK and TypeScript. By following the detailed steps outlined, including configuring YAML files, validating them with Zod, and integrating them into CDK stacks, we've equipped ourselves with a powerful approach to managing cloud infrastructure. While AWS CDK presents challenges in comparison to other tools, its flexibility and capability to leverage programming languages for infrastructure management provide significant advantages in scalability and customisation. This guide serves as a comprehensive resource for developers looking to streamline their deployment workflows and maintain consistency across different environments, ultimately achieving more efficient and secure cloud deployments with AWS CDK.

Code Repo - https://github.com/praveenkpatidar/cdk-demo-multi-env

08/01/2024