+1 (726) 207-9872

Terraform for the Platform, Serverless Framework for the Functions: Sharing VPCs, Secrets, and Ownership Boundaries

On almost every enterprise engagement we walk into the same situation: a platform team already runs Terraform (or OpenTofu) for VPCs, subnets, RDS clusters, KMS keys and IAM baselines, and an application team wants to ship Lambda functions quickly with the Serverless Framework. Somebody then proposes that everything move into one tool, and the project stalls for a quarter.

You do not have to pick. The two tools coexist well if the boundary between them is explicit and the handoff happens through a published contract rather than copy-pasted ARNs. This post is the pattern we implement, with the configuration for both sides.

Why not put everything in one tool?

Because the two halves of a serverless estate change at different rates and belong to different people.

Terraform / OpenTofuServerless Framework V4
ChangesWeeks or monthsMany times a day
OwnerPlatform / cloud teamApplication team
Good atNetworking, data stores, org-wide IAM, non-AWS providersFunction packaging, event wiring, per-stage app stacks
StateTerraform state backendCloudFormation stacks

Forcing a git push of an application handler through a Terraform pipeline that a platform team gatekeeps kills the deploy loop that made serverless attractive. Forcing a VPC into a CloudFormation stack owned by an application repo means a serverless remove can take down the database. Split it at the seam.

The rule: long-lived infrastructure in Terraform, per-service stacks in Serverless Framework

A boundary that has held up across many clients:

Terraform owns VPCs, subnets, NAT, route tables, security groups intended for shared use, RDS/Aurora clusters and RDS Proxy, ElastiCache, KMS keys, Route 53 zones, ACM certificates, shared S3 buckets holding data with a lifecycle longer than the app, EventBridge custom buses shared by several services, SNS topics used across teams, Cognito user pools, org-level IAM roles and permission boundaries, and the CI deploy roles themselves.

Serverless Framework owns Lambda functions and their code packaging, API Gateway / HTTP API / AppSync definitions for that service, function-scoped IAM role statements, queues and DLQs used only by that service, EventBridge rules and schedules that target that service's functions, log groups and retention, alarms on those functions, and anything you are happy to destroy when the service is retired.

The test question for any resource: if this service is deleted next month, should this resource disappear with it? Yes means Serverless Framework. No means Terraform.

The contract: SSM Parameter Store

Do not hardcode VPC IDs into serverless.yml, and do not have Terraform reach into CloudFormation outputs. Have Terraform publish what applications are allowed to consume, under a namespaced path, and have Serverless Framework read it at deploy time.

Terraform side:

locals {
  prefix = "/platform/${var.env}"
}

resource "aws_ssm_parameter" "private_subnet_ids" {
  name  = "${local.prefix}/vpc/private-subnet-ids"
  type  = "StringList"
  value = join(",", module.vpc.private_subnet_ids)
}

resource "aws_ssm_parameter" "lambda_sg_id" {
  name  = "${local.prefix}/vpc/lambda-security-group-id"
  type  = "String"
  value = aws_security_group.lambda.id
}

resource "aws_ssm_parameter" "rds_proxy_endpoint" {
  name  = "${local.prefix}/db/proxy-endpoint"
  type  = "String"
  value = aws_db_proxy.main.endpoint
}

resource "aws_ssm_parameter" "events_bus_name" {
  name  = "${local.prefix}/events/bus-name"
  type  = "String"
  value = aws_cloudwatch_event_bus.main.name
}

Application side, in serverless.yml:

service: orders-api

provider:
  name: aws
  runtime: nodejs22.x
  stage: ${opt:stage, 'dev'}
  region: eu-west-1
  vpc:
    securityGroupIds:
      - ${ssm:/platform/${sls:stage}/vpc/lambda-security-group-id}
    subnetIds: ${ssm:/platform/${sls:stage}/vpc/private-subnet-ids}
  environment:
    DB_PROXY_ENDPOINT: ${ssm:/platform/${sls:stage}/db/proxy-endpoint}
    EVENT_BUS_NAME: ${ssm:/platform/${sls:stage}/events/bus-name}

A StringList parameter resolves to a list, which is exactly what subnetIds wants — no split gymnastics. Secrets stay out of this path: put database credentials in Secrets Manager, pass only the secret ARN through SSM, and let the function fetch and cache the value at runtime so a credential rotation does not require a redeploy.

Why SSM rather than Terraform remote state or CloudFormation Fn::ImportValue?

  • Remote state data sources require application CI to have read access to the platform state backend, which leaks every attribute in it, including secrets Terraform happens to know.
  • CloudFormation exports cannot be deleted or changed while another stack imports them. One export shared by eight services will eventually block a platform change at the worst moment. (If you do want a hard dependency in one direction, ${cf:stack-name.OutputName} works fine — just be deliberate about it.)
  • SSM parameters are a plain, readable, IAM-scoped, cross-account-capable key/value contract with no coupling between state stores.

Treat the parameter path as a public API: namespaced, documented, versioned when its shape changes, and never renamed without a deprecation window.

Cross-account variants

If the platform lives in a shared-services account, you have two clean options:

  1. Replicate the contract. The platform pipeline writes the same parameter paths into each workload account. Applications stay unaware that anything is cross-account. This is our default.
  2. Resource-share and read across. Share subnets with AWS RAM, and have the application CI role assume a read-only role in the platform account to resolve parameters. Fewer moving parts to keep in sync, more IAM to reason about.

Either way, keep the path identical in every account and stage so serverless.yml contains no conditionals.

IAM: who is allowed to deploy what

Have Terraform create the CI deploy role for each service, with a permission boundary that stops the application pipeline from creating network or account-level resources. A practical shape:

  • Allow cloudformation:* on stacks matching ${service}-${stage}-*.
  • Allow lambda:*, apigateway:*, sqs:*, events:*, logs:*, states:* for resources tagged or named with the service prefix.
  • Allow ssm:GetParameter* on /platform/${stage}/* and iam:PassRole on the function execution roles.
  • Deny ec2:*Vpc*, ec2:*Subnet*, rds:Delete*, kms:ScheduleKeyDeletion, organizations:*, and any iam:* outside the service's own role path.

This is what makes the split safe rather than merely tidy: the application pipeline is unable to damage the platform, so the platform team can stop gatekeeping application deploys. Pair it with GitHub Actions OIDC so no AWS keys are stored anywhere.

CI ordering

The dependency is one-directional — platform first, applications second — which keeps pipelines simple:

# .github/workflows/deploy.yml (application repo)
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/orders-api-deploy
          aws-region: eu-west-1
      - name: Assert platform contract exists
        run: |
          for p in vpc/lambda-security-group-id vpc/private-subnet-ids db/proxy-endpoint; do
            aws ssm get-parameter --name "/platform/${STAGE}/$p" >/dev/null \
              || { echo "Missing platform parameter: $p"; exit 1; }
          done
        env:
          STAGE: prod
      - run: npm ci
      - run: npx serverless deploy --stage prod

That preflight check turns a confusing mid-deploy CloudFormation failure into a one-line error naming the missing contract. Platform changes that alter a parameter's shape get a deprecation period: publish the new path, let services migrate, then remove the old one.

For the reverse direction — Terraform needing to know a function's ARN, for example to attach it to a shared EventBridge bus it owns — resist the temptation to read CloudFormation outputs. Either let the service own its own rule on the shared bus (permitted by a bus policy Terraform manages), or have the service publish its outputs to /services/${service}/${stage}/... and have Terraform read them with an aws_ssm_parameter data source. Contracts both ways, state never shared.

Drift and the shared-tag problem

Two failure modes show up in audits:

Tag drift. Terraform sets default_tags on its provider; the Serverless Framework sets provider.stackTags and provider.tags. Make them the same set — cost centre, owner, environment, service — or your cost reports will have a large "untagged" bucket. Enforce it with a shared config file or a small internal plugin.

Console edits in the seam. Someone adds an inbound rule to the Lambda security group by hand to debug an incident. Terraform will revert it on the next apply; the Serverless Framework will not notice at all. Run terraform plan on a schedule in CI and alert on non-empty plans, and use CloudFormation drift detection on application stacks. The seam is where undocumented changes hide.

What about OpenTofu?

Everything above applies unchanged. Since HashiCorp's 2023 licence change, a meaningful share of platform teams have moved to OpenTofu, the Linux Foundation fork, and the aws_ssm_parameter resource and data source behave identically. The contract pattern is deliberately tool-agnostic: it is just parameters in Parameter Store, so the platform side could be CDK, CloudFormation, Pulumi or a shell script without the application side changing a line.

When to collapse into one tool after all

Two cases where we do recommend consolidating:

  • Very small teams. One team, one repo, three functions, one VPC. Two toolchains is overhead with no organisational benefit. Keep it all in Serverless Framework and put the VPC in resources: — or skip the VPC entirely, which is better advice for most Lambda workloads.
  • A hard organisational mandate, with a platform team that publishes a genuinely good internal Terraform module for Lambda plus API Gateway. If the module exists and is maintained, use it; if it does not exist and nobody is funded to build it, the split pattern gets you shipping this week.

Migration path from a single mega-stack

If your VPC and RDS cluster are currently inside a serverless.yml resources: block, do not delete and recreate them. The safe sequence:

  1. Set DeletionPolicy: Retain and UpdateReplacePolicy: Retain on the stateful resources in the CloudFormation template and deploy. Nothing changes yet, but the resources will now survive removal.
  2. Import the same resources into Terraform with terraform import (or import blocks) and confirm a clean, empty plan.
  3. Have Terraform publish the SSM contract.
  4. Remove the resources from serverless.yml, switch the function configuration to the ${ssm:...} references, and deploy. CloudFormation drops them from the stack without deleting them.
  5. Verify in the console that the function still resolves the same subnet IDs and endpoints, then clean up the retained-policy annotations.

Rehearse it in a lower stage first. Step 1 is the one people skip, and it is the only one that is unrecoverable.


We run this split on regulated clients where the platform team cannot hand over network permissions and the application team cannot wait a sprint for a deploy. If you want help drawing the boundary, building the parameter contract and the permission-boundary policies, or executing the retain-and-import migration, that is our DevOps for Serverless Apps and Serverless Architecture Design work — get in touch.