+1 (726) 207-9872

Serverless Framework Compose: Deploying a Multi-Service Monorepo with Shared Outputs

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:

  1. Export what consumers need as CloudFormation Outputs. Compose reads stack outputs by their logical output name; it does not need Export/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.
  2. 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 apievents 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 api into a brand-new stage without data fails at parameter resolution. Run a full serverless deploy to 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 api needs an output from events and events needs one from api, 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

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.