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 / OpenTofu | Serverless Framework V4 | |
|---|---|---|
| Changes | Weeks or months | Many times a day |
| Owner | Platform / cloud team | Application team |
| Good at | Networking, data stores, org-wide IAM, non-AWS providers | Function packaging, event wiring, per-stage app stacks |
| State | Terraform state backend | CloudFormation 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:
- 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.
- 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}/*andiam:PassRoleon the function execution roles. - Deny
ec2:*Vpc*,ec2:*Subnet*,rds:Delete*,kms:ScheduleKeyDeletion,organizations:*, and anyiam:*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:
- Set
DeletionPolicy: RetainandUpdateReplacePolicy: Retainon the stateful resources in the CloudFormation template and deploy. Nothing changes yet, but the resources will now survive removal. - Import the same resources into Terraform with
terraform import(orimportblocks) and confirm a clean, empty plan. - Have Terraform publish the SSM contract.
- 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. - 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.