Once a serverless product grows past one team, the single serverless.yml starts to hurt. Deploys get slow, unrelated changes ship together, CloudFormation's 500-resource limit looms, and every stack update risks something a different team owns. The usual answer is to split into several services — and then immediately discover the new problem: the API service needs the queue URL from the events service, the events service needs the table name from the data service, and nobody wants to hand-wire ARNs into environment variables.
Serverless Framework Compose solves that. It is built into the CLI from V3.15 onward and is a first-class part of V4: one serverless-compose.yml at the repository root that declares your services, wires outputs from one into the parameters of another, and deploys them in dependency order — in parallel where it can.
This tutorial builds a three-service monorepo end to end and shows the operational details that matter: shared outputs, dependency ordering, per-service commands, stage handling, and how this fits into CI.
The example: three services
acme-platform/
├─ serverless-compose.yml
├─ services/
│ ├─ data/ # DynamoDB table + a stream consumer
│ ├─ events/ # EventBridge bus + SQS queue + worker
│ └─ api/ # HTTP API that writes to the table and publishes events
└─ package.json
Dependency direction: api depends on data and events; events depends on data. Each service keeps its own serverless.yml, its own CloudFormation stack, its own deploy lifecycle, and can be deployed alone.
1. The data service
services/data/serverless.yml:
service: acme-data
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
architecture: arm64
resources:
Resources:
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service}-${sls:stage}-orders
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
Outputs:
OrdersTableName:
Value: !Ref OrdersTable
OrdersTableArn:
Value: !GetAtt OrdersTable.Arn
Two rules that make a service composable:
- Export what consumers need as CloudFormation
Outputs. Compose reads stack outputs by their logical output name; it does not needExport/Fn::ImportValue, and that is a feature — cross-stack exports lock resources so they cannot be deleted or renamed while another stack imports them. Compose reads the value at deploy time instead, so services stay independently deployable. - Never reach into another service's internals. If it is not an output, it is private.
2. The events service, consuming data's outputs
services/events/serverless.yml accepts what it needs as parameters:
service: acme-events
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
environment:
ORDERS_TABLE: ${param:ordersTableName}
iam:
role:
statements:
- Effect: Allow
Action: ['dynamodb:GetItem', 'dynamodb:UpdateItem']
Resource: ${param:ordersTableArn}
functions:
worker:
handler: src/worker.handler
events:
- sqs:
arn: !GetAtt OrderQueue.Arn
batchSize: 10
functionResponseType: ReportBatchItemFailures
resources:
Resources:
OrderQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:service}-${sls:stage}-orders
VisibilityTimeout: 180
RedrivePolicy:
maxReceiveCount: 5
deadLetterTargetArn: !GetAtt OrderDlq.Arn
OrderDlq:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:service}-${sls:stage}-orders-dlq
MessageRetentionPeriod: 1209600
Outputs:
OrderQueueUrl:
Value: !Ref OrderQueue
OrderQueueArn:
Value: !GetAtt OrderQueue.Arn
${param:...} resolves to whatever the caller passes. Deployed standalone you can supply it with --param="ordersTableName=..."; deployed through Compose, the value comes from the other stack automatically.
3. The root serverless-compose.yml
services:
data:
path: services/data
events:
path: services/events
params:
ordersTableName: ${data.OrdersTableName}
ordersTableArn: ${data.OrdersTableArn}
api:
path: services/api
params:
ordersTableName: ${data.OrdersTableName}
ordersTableArn: ${data.OrdersTableArn}
orderQueueUrl: ${events.OrderQueueUrl}
orderQueueArn: ${events.OrderQueueArn}
That is the whole wiring layer. The ${data.OrdersTableName} syntax means "the OrdersTableName output of the data service", and referencing it is the dependency declaration — Compose builds the graph from these references. There is no dependsOn to maintain and no ordering to get wrong.
Deploy everything:
serverless deploy
Compose deploys data first, then events and api — events and api in parallel if neither references the other. Output is grouped per service so you can tell whose CloudFormation event just failed.
4. Day-to-day commands
Working on one service does not require redeploying the platform:
serverless api:deploy # just the api service
serverless api:deploy function --function createOrder # single function, fastest loop
serverless api:logs --function createOrder --tail
serverless api:info
serverless deploy --service=api # equivalent long form
serverless info # outputs for every service
serverless remove # tears down in reverse dependency order
A useful detail: serverless api:deploy still resolves api's parameters, which means Compose reads the outputs of data and events from their deployed stacks. You get correct wiring without deploying the dependencies — as long as they exist in that stage.
5. Stages and per-stage values
Compose passes the stage down to every service:
serverless deploy --stage staging
You can also declare shared, stage-aware parameters at the root:
stages:
default:
params:
logLevel: DEBUG
alarmEmail: dev-alerts@example.com
prod:
params:
logLevel: WARN
alarmEmail: oncall@example.com
services:
data:
path: services/data
events:
path: services/events
params:
ordersTableName: ${data.OrdersTableName}
ordersTableArn: ${data.OrdersTableArn}
logLevel: ${param:logLevel}
Keep secrets out of this file. Reference SSM or Secrets Manager from within each service (${ssm:/acme/${sls:stage}/db-password}) so the value is resolved at deploy time and never committed.
6. Ephemeral environments
Because every service name is stage-suffixed, a full disposable copy of the platform is one flag:
serverless deploy --stage pr-482
# ... run integration tests ...
serverless remove --stage pr-482
This is where the monorepo split pays for itself in review: a whole three-stack environment per pull request, torn down in reverse order when the branch merges.
7. Compose in CI
The naive pipeline runs serverless deploy on every merge and redeploys all three stacks. Better: deploy only what changed, and let Compose handle the ordering when it needs to.
# .github/workflows/deploy.yml (excerpt)
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC, no stored AWS keys
contents: read
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy
aws-region: us-east-1
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Deploy changed services
env:
SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}
run: |
CHANGED=$(git diff --name-only origin/main...HEAD | grep '^services/' | cut -d/ -f2 | sort -u)
if [ -z "$CHANGED" ]; then
echo "No service changes"; exit 0
fi
for svc in $CHANGED; do
npx serverless "$svc:deploy" --stage prod
done
Two guardrails worth adding: if serverless-compose.yml itself changed, fall back to a full serverless deploy, and deploy in the order Compose reports from serverless info rather than alphabetically if multiple dependent services changed in the same commit.
Things that will trip you up
- Outputs must exist before a dependent deploys. Deploying
apiinto a brand-new stage withoutdatafails at parameter resolution. Run a fullserverless deployto bootstrap a stage, then use per-service deploys. - Compose does not merge IAM or resources. Each service still owns its own stack and its own roles. Shared IAM policies belong in a small platform service that exports policy ARNs.
- Renaming an output is a breaking change. Add the new output, migrate consumers, then remove the old one — the same discipline you would apply to an API contract.
- Do not split by layer. Splitting into "all the Lambdas", "all the tables", "all the queues" recreates the monolith with extra latency. Split by ownership and change frequency: a service should be something one team deploys on its own cadence.
- Circular references are rejected. If
apineeds an output fromeventsandeventsneeds one fromapi, the dependency is wrong; usually a shared resource belongs in a third service, or the runtime lookup should go through SSM Parameter Store instead of deploy-time wiring.
When not to use Compose
If you have one team and fewer than about 60 CloudFormation resources, a single service is simpler and faster to deploy. Compose earns its place when you have multiple teams, independent release cadences, or a stack approaching CloudFormation's per-stack limits. Splitting early is one of the more common self-inflicted wounds we are called in to unwind.
Related reading
- Event-driven serverless with EventBridge, SQS, and idempotency — what goes inside the
eventsservice. - CI/CD for Serverless Framework apps with GitHub Actions and OIDC — the deploy role assumed above.
- Upgrading a service from Serverless Framework V3 to V4 — Compose is built in from V3.15, and V4 is where it is best supported.
Splitting a monolithic serverless application into well-bounded services is a design problem before it is a configuration problem. Our Microservices with Serverless Framework and Serverless Architecture Design teams do this work with clients every week — get in touch if you would like a second opinion on where your seams should be.