MongoDB Atlas with AWS CDK and VPC Peering

MongoDB Atlas with AWS CDK and VPC Peering

MongoDB Atlas with AWS CDK and VPC Peering: A Cost-Effective Approach

When building cloud applications that require MongoDB Atlas, establishing secure and cost-effective connectivity to your existing AWS infrastructure is crucial. This comprehensive tutorial demonstrates how to create an AWS CDK stack that deploys a MongoDB Atlas cluster with VPC peering to connect to your existing VPC network - a solution that significantly reduces costs compared to using MongoDB’s VPC endpoints.

Why VPC Peering Over VPC Endpoints?

Before diving into the implementation, let’s understand the cost benefits:

  • VPC Endpoints: MongoDB VPC endpoints can cost $45-90+ per month depending on usage
  • VPC Peering: Typically costs $0.01 per GB of data transferred across peering connections
  • Network Performance: Direct peering often provides better latency and throughput
  • Simplified Architecture: No additional endpoint management overhead

For most applications, VPC peering provides a more economical solution while maintaining enterprise-grade security.

Prerequisites

Before we begin, ensure you have:

  1. MongoDB Atlas Account: Sign up via AWS Marketplace for integrated billing
  2. MongoDB Atlas Programmatic API Key (PAK): Create an API key with Organization Project Creator permissions
  3. AWS Account: Configured with AWS CLI and appropriate permissions
  4. Store MongoDB Atlas PAK in AWS Secrets Manager: For secure credential management
  5. Node.js 18+: Required for AWS CDK
  6. Activate CloudFormation Resources: Enable these third-party resources in your target AWS region via the CloudFormation Registry:
    • MongoDB::Atlas::Project
    • MongoDB::Atlas::Cluster
    • MongoDB::Atlas::DatabaseUser
    • MongoDB::Atlas::ProjectIpAccessList
    • MongoDB::Atlas::NetworkContainer
    • MongoDB::Atlas::NetworkPeering

Important: You must activate these MongoDB Atlas CloudFormation resources in each AWS region where you plan to deploy. This is a one-time setup per region.

Step 1: Initial Setup

Install AWS CDK

npm install -g aws-cdk

Bootstrap Your AWS Environment

cdk bootstrap aws://ACCOUNT_NUMBER/REGION

Replace ACCOUNT_NUMBER with your AWS account number and REGION with your target region.

Initialize a New CDK Project

mkdir mongodb-atlas-vpc-peering
cd mongodb-atlas-vpc-peering
cdk init app --language typescript

Install Required Dependencies

npm install awscdk-resources-mongodbatlas
npm install @types/node

Step 2: Understanding the Architecture

Our CDK construct will create the following infrastructure:

┌─────────────────────┐    VPC Peering    ┌─────────────────────┐
│     AWS VPC         │◄─────────────────►│   MongoDB Atlas     │
│                     │                   │                     │
│ ┌─────────────────┐ │                   │ ┌─────────────────┐ │
│ │ Private Subnet  │ │                   │ │    Cluster      │ │
│ │                 │ │                   │ │   (M10+)        │ │
│ │ ┌─────────────┐ │ │                   │ │                 │ │
│ │ │Lambda Function│ │ │                   │ └─────────────────┘ │
│ │ └─────────────┘ │ │                   │                     │
│ └─────────────────┘ │                   └─────────────────────┘
│                     │
└─────────────────────┘

Components created:

  1. MongoDB Atlas Project: Container for your cluster
  2. Network Container: Atlas-side networking configuration
  3. VPC Peering Connection: Secure tunnel between Atlas and AWS VPC
  4. MongoDB Cluster: The actual database with appropriate specs
  5. Database User: Authentication credentials stored in AWS Secrets Manager
  6. IP Access List: Security rules for private network access
  7. Route Table Updates: Automatic routing configuration for your VPC subnets

Step 3: Create the MongoDB Atlas Infrastructure Construct

Create a new file lib/mongodb-atlas-construct.ts and implement the following construct:


import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as atlas from 'awscdk-resources-mongodbatlas';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import { SetRequired } from 'type-fest';

interface MongoAtlasConstructProps {
  ebsVolumeType: string,
  instanceSize: string,
  nodeCount: number,
  region: string,
  vpc: ec2.IVpc
  enableBackup: boolean
  atlasCidr: string
  autoScaling?: atlas.AdvancedAutoScaling
  dbName: string
  dbUserName: string
  projectName: string
  atlasOrgId: string
  atlasProfileName: string
  accessList?: atlas.IpAccessListProps['accessList']
}

export class MongoAtlasConstruct extends Construct {
  mCluster: atlas.CfnCluster
  secret: secretsmanager.Secret
  atlasRegion: string
  atlasNetworkContainer: atlas.CfnNetworkContainer
  atlasNetworkPeering: atlas.CfnNetworkPeering
  mProject: atlas.CfnProject
  clusterName: string

  constructor(scope: Construct, id: string, private readonly props: MongoAtlasConstructProps) {
    super(scope, id);

    // Validate instance size - only dedicated instances are supported
    if (["M0", "M2", "M5"].includes(props.instanceSize)) {
      throw new Error(`Instance size ${props.instanceSize} is not supported. Only dedicated instances (M10 and above) are allowed.`);
    }

    // Since we only support dedicated instances, use private access via VPC CIDR
    // and concatenate with any additional access list from props
    let accessList: atlas.IpAccessListProps['accessList'] = [
      { cidrBlock: props.vpc.vpcCidrBlock, comment: 'Private access via VPC CIDR' },
    ]

    if (props.accessList) {
      accessList = accessList.concat(props.accessList)
    }

    this.atlasRegion = props.region.toUpperCase().replace(/-/g, "_")

    // Use AWS provider for dedicated instances
    let providerName = atlas.AdvancedRegionConfigProviderName.AWS

    this.secret = new secretsmanager.Secret(this, 'DatabaseSecret', {
      generateSecretString: {
        secretStringTemplate: JSON.stringify({ username: props.dbUserName }),
        generateStringKey: 'password',
        passwordLength: 12,
        excludePunctuation: true,
      },
    });

    // Configure backup options for dedicated instances
    let backupOpts = {
      pitEnabled: props.enableBackup,
      backupEnabled: props.enableBackup,
      terminationProtectionEnabled: props.enableBackup,
    }

    this.atlasVpcPeering(id,
      {
        dbUserProps: {
          username: this.secret.secretValueFromJson('username').unsafeUnwrap(),
          password: this.secret.secretValueFromJson('password').unsafeUnwrap()
        },
        clusterProps: {
          name: 'Cluster',
          ...backupOpts,
          replicationSpecs: [
            {
              numShards: 1,
              advancedRegionConfigs: [
                {
                  electableSpecs: {
                    ebsVolumeType: props.ebsVolumeType,
                    instanceSize: props.instanceSize,
                    nodeCount: props.nodeCount
                  },
                  priority: 7,
                  regionName: this.atlasRegion,
                  providerName,
                  backingProviderName: "AWS",
                  autoScaling: props.autoScaling
                }]
            }],
        },
        projectProps: {
          orgId: props.atlasOrgId,
          name: props.projectName,
        },
        ipAccessListProps: {
          accessList: accessList,
        },
        profile: props.atlasProfileName,
      }
    )
  }

  atlasVpcPeering(id: string, props: atlas.AtlasBasicProps & {
    dbUserProps: SetRequired<atlas.DatabaseUserProps, 'username' | 'password'>,
  }) {
    this.mProject = new atlas.CfnProject(this, "Project", {
      profile: props.profile,
      name: props.projectProps.name ||
        projectDefaults.projectName.concat(String(randomNumber())),
      ...props.projectProps,
    });

    // Create network container and peering for dedicated instances
    this.atlasNetworkContainer = new atlas.CfnNetworkContainer(this, 'NetworkContainer', {
      vpcId: this.props.vpc.vpcId,
      atlasCidrBlock: this.props.atlasCidr,
      projectId: this.mProject.attrId,
      regionName: this.atlasRegion,
      profile: props.profile,
    })

    this.atlasNetworkPeering = new atlas.CfnNetworkPeering(this, 'NetworkPeering', {
      containerId: this.atlasNetworkContainer.attrId,
      projectId: this.mProject.attrId,
      vpcId: this.props.vpc.vpcId,
      accepterRegionName: this.props.vpc.env.region,
      awsAccountId: this.props.vpc.env.account,
      profile: props.profile,
      routeTableCidrBlock: this.props.vpc.vpcCidrBlock,
    });
    // Create a new MongoDB Atlas Cluster and pass project ID
    this.clusterName = props.clusterProps.name ||
      clusterDefaults.clusterName.concat(String(randomNumber()))
    this.mCluster = new atlas.CfnCluster(this, "Cluster", {
      profile: props.profile,
      name: this.clusterName,
      projectId: this.mProject.attrId,
      clusterType: clusterDefaults.clusterType,
      ...props.clusterProps,
    });

    // Add dependencies for network resources
    this.mCluster.addDependency(this.atlasNetworkContainer)
    this.mCluster.addDependency(this.atlasNetworkPeering)
    // Create a new MongoDB Atlas Database User
    const _mDBUser = new atlas.CfnDatabaseUser(this, "User", {
      profile: props.profile,
      ...props.dbUserProps,
      databaseName: props.dbUserProps?.databaseName || dbDefaults.dbName,
      projectId: this.mProject.attrId,
      roles: props.dbUserProps?.roles || dbDefaults.roles,
    });
    // Create a new MongoDB Atlas Project IP Access List
    const _ipAccessList = new atlas.CfnProjectIpAccessList(this, "IpAccess", {
      profile: props.profile,
      projectId: this.mProject.attrId,
      ...props.ipAccessListProps,
    });

    // Create routes from private subnets to Atlas, allow your application to access the cluster
    const selectSubnets = this.props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnets

    selectSubnets.forEach((subnet, index) => {
      new ec2.CfnRoute(this, 'AwsPeerToAtlasRoute' + index, {
        routeTableId: subnet.routeTable.routeTableId,
        destinationCidrBlock: this.props.atlasCidr,
        vpcPeeringConnectionId: this.atlasNetworkPeering.attrConnectionId,
      });
    })
  }

  getConnectionHostname() {
    const uri = this.mCluster.getAtt("ConnectionStrings.StandardSrv")?.toString() ?? ''
    const domain = cdk.Fn.select(2, cdk.Fn.split("/", uri))
    return domain
  }
  getDefaultDBName() {
    return this.props.dbName
  }

  getUsername() {
    return ecs.Secret.fromSecretsManager(this.secret, 'username')
  }
  getPassword() {
    return ecs.Secret.fromSecretsManager(this.secret, 'password')
  }
}



/** @type {*} */
const projectDefaults = {
  projectName: "atlas-project-",
};
/** @type {*} */
const dbDefaults = {
  dbName: "admin",
  roles: [
    {
      roleName: "atlasAdmin",
      databaseName: "admin",
    },
  ],
};
/** @type {*} */
const clusterDefaults = {
  clusterName: "atlas-cluster-",
  clusterType: "REPLICASET",
};

/**
 * @description
 * @export
 * @class AtlasBasic
 * @extends {Construct}
 */

function randomNumber() {
  const min = 10;
  const max = 9999999;
  return Math.floor(Math.random() * (max - min + 1) + min);
}

Step 4: Create the Main Stack

Update your main stack file (e.g., lib/mongodb-atlas-vpc-peering-stack.ts):

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { MongoAtlasConstruct } from './mongodb-atlas-construct';
import { SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class MongodbAtlasVpcPeeringStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Get your VPC ready, or use the existing one
    const vpc = new Vpc(this, "Vpc", {
      maxAzs: 2,
      natGateways: 1, // 1 Nat Gateway, testing only
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "Public",
          subnetType: SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "Egress",
          subnetType: SubnetType.PRIVATE_WITH_EGRESS,
        },
        {
          cidrMask: 24,
          name: "Private",
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });
    // Define your MongoDB Atlas configuration
    // Replace them your configurations, or use context, environment variables as whatever you prefer
    const atlasOrgId = this.node.tryGetContext('mongodb-atlas:org-id'); // Replace with your Atlas Org ID
    const atlasProfileName = this.node.tryGetContext('mongodb-atlas:profile'); // Replace with your Atlas profile name
    const dbName = 'my-app'; // Replace with your desired database name
    const dbUserName = 'my-app-user'; // Replace with your desired database user name
    const projectName = 'my-app-project'; // Replace with your desired project name
    const atlasCidr = '192.168.8.0/21'; // Replace with your desired Atlas CIDR block
    const region = props?.env?.region || 'ap-southeast-2'; // Provide the stack region, default to 'ap-southeast-2'

    const mongoAtlasConstruct = new MongoAtlasConstruct(this, 'MongoDBCluster', {
      atlasOrgId: atlasOrgId,
      atlasProfileName: atlasProfileName,
      projectName: projectName,
      dbName: dbName,
      dbUserName: dbUserName,
      ebsVolumeType: 'STANDARD',
      instanceSize: 'M10', // Use M10+ for VPC peering support
      nodeCount: 3,
      region: region,
      vpc: vpc,
      enableBackup: false, // Enable backups and termination protection
      atlasCidr: atlasCidr,
    });

    // Create a Lambda function to test MongoDB connectivity, inside Egress Subnet
    const connectivityTestLambda = new NodejsFunction(this, 'MongoDBConnectivityTest', {
      runtime: Runtime.NODEJS_20_X,
      handler: 'index.handler',
      entry: 'lambda/connectivity-test.ts', // Path to your Lambda function code
      vpc: vpc,
      vpcSubnets: {
        subnetType: SubnetType.PRIVATE_WITH_EGRESS
      },
      environment: {
        MONGODB_HOST_NAME: mongoAtlasConstruct.getConnectionHostname(),
        MONGODB_DB_NAME: mongoAtlasConstruct.getDefaultDBName(),
        MONGODB_SECRET_ARN: mongoAtlasConstruct.secret.secretArn, // Use the secret ARN for credentials
      },

      timeout: cdk.Duration.seconds(30),
      memorySize: 256,
      description: 'Lambda function to test MongoDB Atlas connectivity through VPC peering'
    });

    mongoAtlasConstruct.secret.grantRead(connectivityTestLambda);

    // Output the Lambda function details for easy testing
    new cdk.CfnOutput(this, 'ConnectivityTestLambdaArn', {
      value: connectivityTestLambda.functionArn,
      description: 'ARN of the MongoDB connectivity test Lambda function'
    });

    new cdk.CfnOutput(this, 'ConnectivityTestLambdaName', {
      value: connectivityTestLambda.functionName,
      description: 'Name of the MongoDB connectivity test Lambda function'
    });
  }
}

Step 5: Deploy Your Infrastructure

Set CDK Context

Create a cdk.context.json file with your MongoDB Atlas configuration:

{
  "mongodb-atlas:org-id": "YOUR_MONGODB_ATLAS_ORG_ID",
  "mongodb-atlas:profile": "default"
}

Preview Changes

cdk diff

Deploy the Stack

cdk deploy

Step 6: Accept and Verify VPC Peering Connection

After deployment, go to your VPC peering and accept the connection. Accept the VPC connection from MongoDB

  1. MongoDB Atlas Console: Check that the peering connection shows as “Available”
  2. AWS VPC Console: Verify the peering connection is active
  3. Route Tables: Confirm routes to Atlas CIDR are properly configured
  4. Connectivity Test: Test database connection from an EC2 instance in your VPC

Step 7: Test the MongoDB connection

First, create the Lambda function dependencies. Add to your package.json:

{
  "dependencies": {
    "mongodb": "^6.0.0",
    "@aws-sdk/client-secrets-manager": "^3.0.0"
  }
}

Then create the Lambda function at lambda/connectivity-test.ts:

const { MongoClient } = require('mongodb');
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

exports.handler = async () => {
  const hostName = process.env.MONGODB_HOST_NAME;
  const dbName = process.env.MONGODB_DB_NAME;
  const secretArn = process.env.MONGODB_SECRET_ARN;

  if (!hostName || !dbName || !secretArn) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        success: false,
        message: 'MongoDB configuration not provided (missing MONGODB_HOST_NAME, MONGODB_DB_NAME, or MONGODB_SECRET_ARN)'
      })
    };
  }

  let client;
  try {
    console.log('Retrieving MongoDB credentials from Secrets Manager...');

    // Get credentials from Secrets Manager
    const secretsClient = new SecretsManagerClient({ region: process.env.AWS_REGION });
    const secretCommand = new GetSecretValueCommand({ SecretId: secretArn });
    const secretResponse = await secretsClient.send(secretCommand);

    if (!secretResponse.SecretString) {
      throw new Error('Secret value is empty');
    }

    const secretData = JSON.parse(secretResponse.SecretString);
    const username = secretData.username;
    const password = secretData.password;

    if (!username || !password) {
      throw new Error('Username or password not found in secret');
    }

    // Construct the MongoDB connection string
    const connectionString = `mongodb+srv://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${hostName}/${dbName}?retryWrites=true&w=majority`;

    console.log('Attempting to connect to MongoDB Atlas...');
    console.log('Hostname:', hostName);
    console.log('Database:', dbName);

    client = new MongoClient(connectionString);
    await client.connect();

    // Test the connection
    const admin = client.db('admin');
    const result = await admin.command({ ping: 1 });

    // Additional connectivity tests
    const testDb = client.db(dbName);
    const collections = await testDb.listCollections().toArray();

    console.log('Successfully connected to MongoDB Atlas');

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        message: 'Successfully connected to MongoDB Atlas',
        ping: result,
        database: dbName,
        collectionsCount: collections.length,
        collections: collections.map((c: any) => c.name),
        timestamp: new Date().toISOString(),
        vpcInfo: {
          availabilityZone: process.env.AWS_REGION,
          functionName: process.env.AWS_LAMBDA_FUNCTION_NAME
        }
      })
    };
  } catch (error: any) {
    console.error('Failed to connect to MongoDB Atlas:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({
        success: false,
        message: 'Failed to connect to MongoDB Atlas',
        error: error.message,
        errorCode: error.code,
        timestamp: new Date().toISOString(),
        vpcInfo: {
          availabilityZone: process.env.AWS_REGION,
          functionName: process.env.AWS_LAMBDA_FUNCTION_NAME
        }
      })
    };
  } finally {
    if (client) {
      await client.close();
    }
  }
};

Test with the function Test successfully with lambda function inside VPC

Cost Optimization Tips

  1. Choose Appropriate Instance Sizes: Start with M10 and scale based on actual usage
  2. Enable Auto-scaling: Configure compute and storage auto-scaling for cost efficiency
  3. Monitor Data Transfer: VPC peering charges are based on data transferred
  4. Use Private Endpoints: Keep traffic within AWS network to minimize charges
  5. Backup Strategy: Balance backup retention with storage costs

Security Best Practices

  1. Restrict IP Access: Use VPC CIDR blocks instead of 0.0.0.0/0
  2. Rotate Credentials: Implement regular password rotation using AWS Secrets Manager
  3. Network Segmentation: Use private subnets for database connections
  4. Monitoring: Enable MongoDB Atlas and AWS CloudTrail logging
  5. Encryption: Ensure encryption in transit and at rest

Troubleshooting Common Issues

Peering Connection Stuck in “Pending”

  • Verify AWS account ID and VPC ID are correct
  • Check that CIDR blocks don’t overlap
  • Ensure proper IAM permissions for Atlas

Cannot Connect to Database

  • Verify security groups allow MongoDB traffic (port 27017)
  • Check route table configurations
  • Confirm IP access list includes VPC CIDR

High Data Transfer Costs

  • Review application connection patterns
  • Implement connection pooling
  • Consider data locality and read preferences

Cleanup

To avoid unnecessary charges, clean up resources when no longer needed:

cdk destroy

Note: This will delete the MongoDB Atlas cluster and all data. Ensure you have proper backups before destroying production resources.

Conclusion

This approach provides a robust, cost-effective solution for connecting MongoDB Atlas to your AWS infrastructure. By using VPC peering instead of VPC endpoints, you can achieve:

  • 60-80% cost reduction compared to VPC endpoints
  • Better network performance with direct peering
  • Enhanced security with private network connectivity
  • Simplified management through Infrastructure as Code

The CDK construct we’ve built is reusable and can be easily adapted for different environments (development, staging, production) by adjusting the parameters.

For production deployments, consider implementing additional monitoring, alerting, and backup strategies to ensure high availability and data protection.

The full code can be accessed at https://github.com/phanluanint/mongodb-atlas-vpc-peering