Terraform: using existing VPC for private Lambdas
When working with a Terraform VPC Lambda setup, you will not always be creating AWS accounts and networking from scratch. In many projects, the client already has an AWS Landing Zone in place, with much of the foundational infrastructure and configuration already established.
The VPC, private subnets, routing, and part of the security model may already exist in the AWS account. That is especially common in enterprise environments where the account baseline is managed centrally and application teams are expected to plug into it.
That changes the approach a bit case when you need to use private subnets for some services such as RDS or Lambda. The goal is to standardize how Terraform stacks consume existing network resources so that every Lambda that needs private access, for example to Aurora, is attached in the same way.
In this post, I will walk through a simple approach:
- use a shared Terraform stack to read existing networking from the AWS account
- expose the relevant values through outputs
- let application stacks reuse those values through remote state
- attach Lambdas to private subnets only where needed
That keeps the networking model consistent without forcing every service stack to rediscover subnet IDs or invent its own Lambda security group.
Use existing networking
Let’s start with the real-world assumption: the AWS account already contains a VPC and private subnets. Instead of creating them again, the shared stack reads them using data sources since data is used to fetch or compute information from sources outside of your current Terraform configuration without provisioning new infrastructure.
data "aws_vpc" "this" {
tags = {
Name = var.vpc_name
}
}
data "aws_subnets" "private" {
filter {
name = "vpc-id"
values = [data.aws_vpc.this.id]
}
filter {
name = "tag:Name"
values = ["subnet-Private-*"]
}
}What this does is that it takes VPC which name is equal to value of var.vpc_name variable and it takes private subnets which tag is subnet-Private-*. Terraform AWS provider supports both tags and filters for accessing private subnets.
This example uses subnet name patterns to discover existing resources. Depending on your environment, you may prefer to use tags, resource IDs, or other filters. The goal is simply to locate the correct VPC and subnets within the existing Landing Zone.
Define the shared Lambda attachment model once
Even if the subnets already exist, the application still needs a consistent way to attach Lambda functions to the VPC. A simple approach is to define a shared Lambda security group and use it as the default VPC attachment model for private Lambdas.
resource "aws_security_group" "lambda" {
name = "shared-lambda-sg"
description = "Shared security group for VPC-enabled Lambdas"
vpc_id = data.aws_vpc.this.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}In production environments, the egress rules should follow the client’s security requirements. For example, some organizations allow all outbound traffic, while others restrict access to specific internal services, databases, or VPC endpoints.
At this point, the shared stack is doing two things:
- reading the existing private subnets from the account
- defining the standard security group that VPC-enabled Lambdas should use
Export the networking resources
The shared stack can now expose these values through outputs, so application stacks do not need to know how the VPC and subnets were discovered.
output "lambda_subnet_ids" {
description = "Private subnet IDs used by VPC-enabled Lambdas"
value = data.aws_subnets.private.ids
}
output "lambda_security_group_id" {
description = "Shared security group ID used by VPC-enabled Lambdas"
value = aws_security_group.lambda.id
}Application stacks should not need to care whether those subnets were discovered through Terraform data sources, managed in a separate networking repository, or provisioned manually as part of an existing Landing Zone. Their only responsibility is to consume the exported values.
This separation keeps application stacks simpler and makes networking changes easier to manage. If subnet names, tags, or lookup rules change in the future, only the shared networking stack needs to be updated. The application stacks can continue using the same outputs without any modifications.
Consume the shared values from application stacks
Once the shared stack exposes the subnet IDs and security group ID, application stacks can read them through remote state. In the example below, the application stack reads outputs from the shared infrastructure stack stored in a separate Terraform state file.
data "terraform_remote_state" "shared" {
backend = "s3"
workspace = "shared"
config = {
bucket = "example-tfstate"
key = "shared/terraform.tfstate"
encrypt = true
use_lockfile = true
region = "eu-central-1"
}
}Terraform stores its state in a backend, which in this example is an S3 bucket. The key parameter identifies the specific state file within that bucket.
In this case, shared/terraform.tfstate represents the state of the shared infrastructure stack. By reading that state file through terraform_remote_state, application stacks can access the outputs exposed by the shared stack without directly managing those resources themselves.
This allows multiple Terraform stacks to share infrastructure information while keeping ownership clearly separated. The shared stack owns the networking resources, while application stacks only consume the exported values.
Attach only the Lambdas that need private access
Not every Lambda function needs to run inside a VPC. Some functions require access to resources such as Aurora, private APIs, or VPC endpoints, while others can operate entirely outside of the private network. Because of that, application stacks should be able to opt into private networking only when it is required.
Once the shared networking values are available through remote state, attaching a Lambda to the existing VPC becomes straightforward.
resource "aws_lambda_function" "query_api" {
function_name = "query-api"
package_type = "Image"
image_uri = var.image_uri
role = aws_iam_role.lambda.arn
timeout = 30
memory_size = 512
vpc_config {
subnet_ids = data.terraform_remote_state.shared.outputs.lambda_subnet_ids
security_group_ids = [data.terraform_remote_state.shared.outputs.lambda_security_group_id]
}
environment {
variables = {
DB_HOST = data.terraform_remote_state.shared.outputs.aurora_reader_endpoint
DB_NAME = "testdb"
}
}
}Notice that the application stack does not need to know which subnets to use or which security group should be attached to the function. Those decisions have already been made by the shared infrastructure stack. The vpc_config block attaches the Lambda function to the private subnets and shared security group. This means that from data block terraform_remote_state which name is shared we are using two outputs previously created lambda_subnet_ids and lambda_security_group_id.
Important: this also introduces dependency between the two stacks. The shared stack must be deployed first, because Terraform outputs become available only after the state has been created or updated during an apply. If the shared stack has not been deployed yet, the application stack will not be able to read those outputs.
Lambda module usecase
If you are using a reusable Terraform module for Lambda functions, not every function should be forced into a VPC. A useful pattern is to make the VPC configuration optional.This allows the same module to deploy both regular Lambda functions and Lambda functions that need private access.
dynamic "vpc_config" {
for_each = var.vpc_subnet_ids != null && var.vpc_security_group_ids != null ? [1] : []
content {
subnet_ids = var.vpc_subnet_ids
security_group_ids = var.vpc_security_group_ids
}
}variable "vpc_subnet_ids" {
type = list(string)
default = null
}
variable "vpc_security_group_ids" {
type = list(string)
default = null
}When both vpc_subnet_ids and vpc_security_group_ids are provided, Terraform includes the vpc_config block and attaches the Lambda function to the VPC.
When those values are not provided, the vpc_config block is not rendered, and the Lambda function is deployed without VPC attachment.
Use case example with VPC configuration:
module "private_lambda" {
source = "./modules/lambda"
lambda_name = "query-api"
lambda_iam_role_arn = aws_iam_role.lambda.arn
lambda_image_uri = var.lambda_image_uri
vpc_subnet_ids = data.terraform_remote_state.shared.outputs.lambda_subnet_ids
vpc_security_group_ids = [
data.terraform_remote_state.shared.outputs.lambda_security_group_id
]
}Use case example without VPC:
module "public_lambda" {
source = "./modules/lambda"
lambda_name = "fetch-data"
lambda_iam_role_arn = aws_iam_role.lambda.arn
lambda_image_uri = var.lambda_image_uri
}Scaling the pattern across multiple Lambdas
Often in projects you need to deploy dozens of Lambdas not just few. This pattern becomes even more useful when the application stack deploys multiple Lambda functions from the same module. Instead of duplicating module blocks or manually deciding VPC settings for each function, each Lambda can define whether it needs private access.
locals {
lambdas = {
import_data = {
name = "import-data"
memory_size = 2048
timeout = 300
needs_vpc = true
}
webhook_handler = {
name = "webhook-handler"
memory_size = 512
timeout = 30
needs_vpc = false
}
fetch_data = {
name = "fetch-data"
memory_size = 1024
timeout = 120
needs_vpc = true
}
...
}
}In this case the module call would look like this:
module "lambda" {
for_each = local.lambdas
source = "../../modules/lambda"
lambda_name = "${each.value.name}-${var.env}"
lambda_image_uri = var.lambda_image_uri
lambda_iam_role_arn = aws_iam_role.lambda_role.arn
lambda_memory_size = each.value.memory_size
lambda_timeout = each.value.timeout
vpc_subnet_ids = each.value.needs_vpc ?
data.terraform_remote_state.shared.outputs.lambda_subnet_ids :
null
vpc_security_group_ids = each.value.needs_vpc ?
[data.terraform_remote_state.shared.outputs.lambda_security_group_id] :
null
}The needs_vpc flag controls whether the module receives VPC networking values.
If needs_vpc is true, the Lambda gets the subnet IDs and security group ID from the shared stack. If it is false, those values are passed as null, and the module does not render the vpc_config block.
This keeps the module reusable for both private and non-private Lambdas, while still enforcing one consistent VPC attachment model for the functions that need it.
Conclusion
This pattern works particularly well in multi-stack Terraform environments where networking already exists and needs to be consumed consistently across multiple services.
Instead of having every stack discover subnets, create security groups, and define its own VPC attachment model, a shared stack exposes a reusable networking interface that application stacks can consume through remote state.
The result is a simpler and more predictable setup: Lambdas that need private access can opt in, those that do not can remain outside the VPC, and networking decisions are defined once and reused everywhere. This reduces duplication, prevents configuration drift, and makes future changes much easier to manage.







