Skip to content

Exit Serverless to Jsonnet

Convert Serverless Framework or SAM-generated CloudFormation templates into Jsonnet source files using cfn.libsonnet, sls.libsonnet, and sam.libsonnet.

When to use

The user provides a CloudFormation JSON template generated by sls package or pulled from a deployed stack. For SAM, use the processed deployed template (aws cloudformation get-template --template-stage Processed), because sam build does not expand the transform to its final raw resources.

Inputs

  • A CloudFormation JSON template (file path or inline)
  • Which library to target: sls.libsonnet (explicit wiring) or sam.libsonnet (SAM-style events)
  • Service name and stage (extract from the template if not provided)

Process

Step 1: Inventory the template

Read the template and classify every resource by pattern. Use this table:

CFN resource type Count it as Notes
AWS::S3::Bucket named ServerlessDeploymentBucket Deployment bucket Use sls.deploymentBucketG
AWS::S3::BucketPolicy on that bucket Deployment bucket Included in sls.deploymentBucketG
AWS::IAM::Role with AssumeRolePolicyDocument allowing lambda.amazonaws.com Lambda execution role Use sls.iamRoleG()
AWS::Logs::LogGroup with name /aws/lambda/... Lambda triplet (⅓) Part of sls.lambdaFnG()
AWS::Lambda::Function Lambda triplet (⅔) Part of sls.lambdaFnG()
AWS::Lambda::Version referencing a function Lambda triplet (3/3) Part of sls.lambdaFnG()
AWS::Logs::SubscriptionFilter on a log group Log forwarding Use logDestination param on sls.lambdaFnG()
AWS::Events::Rule with ScheduleExpression Schedule event Use sls.scheduleEventG()
AWS::Lambda::Permission for events.amazonaws.com Schedule event (companion) Included in sls.scheduleEventG()
AWS::SQS::Queue SQS queue Use sls.sqsQueue()
AWS::Lambda::EventSourceMapping with SQS source SQS event Use sls.sqsEventSource()
AWS::Lambda::EventSourceMapping with DynamoDB source DynamoDB stream Not yet in library — use raw Jsonnet object
AWS::ApiGateway::RestApi REST API (v1) Use sls.restApiG()
AWS::ApiGateway::Resource REST API path segment Use sls.apiResource()
AWS::ApiGateway::Method with Type: AWS_PROXY REST API route Use sls.apiMethod()
AWS::ApiGateway::Method with Type: MOCK and HttpMethod: OPTIONS CORS mock Use sls.corsOptions()
AWS::ApiGateway::Deployment REST API deployment Use sls.apiDeployment()
AWS::Lambda::Permission for apigateway.amazonaws.com API GW permission Use sls.apiLambdaPermission()
AWS::ApiGateway::ApiKey API key Part of API key bundle (manual block or sam.Api with UsagePlan)
AWS::ApiGateway::UsagePlan Usage plan Part of API key bundle
AWS::ApiGateway::UsagePlanKey Usage plan key Part of API key bundle
AWS::ApiGatewayV2::Api HTTP API (v2) Use sls.httpApiG()
AWS::ApiGatewayV2::Stage HTTP API stage Included in sls.httpApiG()
AWS::ApiGatewayV2::Authorizer JWT authorizer Use sls.httpApiJwtAuthorizer()
AWS::ApiGatewayV2::Integration HTTP API integration Part of sls.httpApiFnG()
AWS::ApiGatewayV2::Route HTTP API route Part of sls.httpApiFnG()
AWS::Lambda::Permission for apigateway.amazonaws.com (v2) HTTP API permission Included in sls.httpApiFnG()
AWS::EC2::SecurityGroup VPC security group Pass-through as raw Jsonnet object
AWS::DynamoDB::Table DynamoDB table Pass-through as raw Jsonnet object
AWS::SNS::Topic SNS topic Pass-through as raw Jsonnet object
AWS::SNS::Subscription SNS subscription Pass-through as raw Jsonnet object
AWS::SQS::QueuePolicy Queue policy Pass-through as raw Jsonnet object
Anything with Custom:: type SLS custom resource Copy verbatim as raw Jsonnet block

Step 1b: Recognize interpolation hacks

If you have access to the serverless.yml (not just the generated JSON), look for the interpolation patterns below. Each one represents a value that was resolved at a different time in the SLS pipeline. Understanding when each resolves tells you how to replace it in Jsonnet.

${self:...} / ${opt:...} — resolved at package time. SLS bakes these into the generated JSON as string literals. ${self:provider.stage} becomes "dev", ${opt:region} becomes "eu-west-1". In Jsonnet, replace with std.extVar('stage') or a local variable. If the serverless.yml uses custom.defaultStage / custom.defaultRegion placeholder values (like default-stage), these placeholders will appear as literals in the generated JSON — see the sed pattern below.

#{AWS::AccountId} / #{AWS::Region} — the serverless-cf-vars plugin. This plugin rewrites #{...} to ${...} in the output JSON so that CloudFormation's Fn::Sub resolves them at deploy time. Without the plugin, SLS would try to resolve ${AWS::AccountId} as its own variable and fail. In the generated JSON these appear inside Fn::Sub expressions. In Jsonnet, use cfn.sub(...) or cfn.arn(...).

${file(path):field} — file-based lookups, resolved at package time. Common variants: - ${file(build_info.yml):Branch} — CI build metadata injected into tags. In Jsonnet, replace with std.extVar('branch') passed at render time. - ${file(references.yml):${self:provider.stage}.subnetIds} — stage-keyed config in a separate YAML file. In Jsonnet, replace with std.extVar or an imported Jsonnet config object.

default-* placeholder + sed — the most fragile pattern. When one sls package output is deployed to multiple stages/regions, the serverless.yml uses made-up defaults (default-stage, default-kmskey, default-cmosEventsTopic, etc.) that appear as literals in the generated JSON. The deploy script then runs sed -i "s/default-stage/${REAL_VALUE}/g" on the JSON before sls deploy --package. This is global string replacement on machine-generated JSON — if the placeholder appears in an unexpected context, or a real value contains the placeholder string, things break silently. Larger services can have 8+ sed replacements. In Jsonnet, all of these become std.extVar(...) — resolved at render time with no string replacement. This is often the single biggest reliability win of the migration.

{{resolve:ssm:/path}} — CloudFormation dynamic references. These pass through SLS untouched and are resolved by CloudFormation at deploy time. Common for Splunk destinations, VPC IDs, subnet IDs, KMS keys. In Jsonnet, keep these as literal strings — they work in raw CloudFormation JSON unchanged.

When converting, check the deploy.sh / deployspec.yml alongside the serverless.yml. The sed replacements in the deploy script reveal which values are parameterized at deploy time — these are your std.extVar candidates. Values that only appear in ${self:...} expressions are package-time constants that become locals or ext-vars in Jsonnet.

Step 2: Extract parameters

From the template, identify:

  • Service name: usually in the IAM role name pattern {service}-{stage}-{region}-lambdaRole
  • Stage: from function names {service}-{stage}-{functionKey}, or tag values
  • S3 key: from Code.S3Key on Lambda functions
  • CodeSha256: from Lambda::Version resources
  • Tags: from any tagged resource — collect the common tag set
  • Environment variables: from Lambda::Function.Properties.Environment.Variables
  • Extra IAM statements: everything in the IAM role's PolicyDocument except the standard CloudWatch log permissions (CreateLogStream, CreateLogGroup, TagResource, PutLogEvents scoped to the service prefix)
  • Managed policies: from ManagedPolicyArns on the IAM role (e.g. AWSLambdaVPCAccessExecutionRole)
  • VPC config: from VpcConfig on Lambda functions
  • Log destinations: from SubscriptionFilter.Properties.DestinationArn

Step 3: Choose the library level

Three libraries are available, each at a different abstraction level:

Use aws.libsonnet (clean-room) when: - The user is not migrating an existing stack in-place (no logical ID preservation needed) - The template has custom IAM roles (not the standard SLS-generated shared role) - The user wants a chainable builder API: aws.lambda() returns an object with .routes(), .schedule(), .sqsSource(), .logSubscription() methods - HTTP API v2 with JWT auth maps cleanly to aws.httpApiG() + aws.jwtAuthorizer() + the lambda builder's .routes() method

Use sls.libsonnet (SLS-compatible) when: - Migrating an existing SLS stack in-place — logical IDs match SLS conventions (IamRoleLambdaExecution, ServerlessDeploymentBucket, {Prefix}LambdaFunction) - The template uses the standard SLS pattern (one shared IAM role, deployment bucket) - Resources have unusual DependsOn chains or Conditions that need explicit wiring

Use sam.libsonnet (SAM-shaped input) when: - The template follows the standard SLS/SAM pattern (one role, functions with events) - The user prefers conciseness over explicit control - The user is familiar with SAM concepts (Events inside Functions)

Step 4: Write the Jsonnet file

With sls.libsonnet (preserves SLS logical IDs):

local cfn = import 'lib/cfn.libsonnet';
local sls = import 'lib/sls.libsonnet';

local service = '{extracted service name}';
local stage   = std.extVar('stage');
local tags = cfn.tags({ ... });
local extraStatements = [ ... ];

{
  AWSTemplateFormatVersion: '2010-09-09',
  Resources:
    sls.deploymentBucketG
    + sls.iamRoleG(service + '-' + stage, extraStatements)
    + sls.lambdaFnG(...)
    + ...remaining resources...,
  Outputs: { ... },
}

With aws.libsonnet (clean logical IDs, chainable builders):

local cfn = import 'lib/cfn.libsonnet';
local aws = import 'lib/aws.libsonnet';

local service = '{extracted service name}';
local stage   = std.extVar('stage');
local tags = cfn.tags({ ... });

local app = aws.lambda('App', {
  FunctionName: service + '-' + stage + '-app',
  Handler: 'wsgi_handler.handler',
  Code: { S3Bucket: '...', S3Key: '...' },
  Role: cfn.getArn('AppRole'),
  Tags: tags,
});

{
  AWSTemplateFormatVersion: '2010-09-09',
  Resources:
    app.resources
    + app.routes('Api', {
        Default: { routeKey: '$default', authorizerId: cfn.ref('ApiAuth') },
      })
    + app.schedule('Cleanup', 'cron(0 3 * * ? *)')
    + aws.httpApiG('Api', service + '-' + stage)
    + {
        ApiAuth: aws.jwtAuthorizer('Api', 'jwt', issuer, audience),
        AppRole: aws.lambdaRole(service + '-' + stage, extraStatements),
      },
  Outputs: { ... },
}

Key rules: - Parameterize stage with std.extVar('stage') so one file generates all stages - Put tags in a local and pass to every lambdaFn call - Put the log forwarding destination in a local and pass as logDestination - Keep service-specific resources (DynamoDB tables, SecurityGroups, SNS topics) as raw Jsonnet objects — don't force them into library helpers - Preserve the original logical IDs if the user needs to deploy to an existing stack without replacement

Step 5: Verify

Instruct the user to run:

jsonnet --preserve-field-order --ext-str stage=dev my-service.jsonnet > generated.json
diff <(python3 -m json.tool original.json) <(python3 -m json.tool generated.json)

Expected differences: - Lambda::Version logical IDs (SLS embeds a hash, Jsonnet uses a stable name) - Minor formatting

Note: Use --preserve-field-order (available in go-jsonnet-fork) to keep keys in source order. Without it, Jsonnet sorts keys alphabetically, producing noisy diffs against the original template.

Unexpected differences to investigate: - Missing resources (forgot to include a pattern) - Wrong ARN references (logical ID mismatch) - Missing tags or environment variables

Pattern recognition guide

The Lambda triplet

The most common pattern. Always appears as exactly three resources with a shared name prefix:

{Prefix}LogGroup          → AWS::Logs::LogGroup
{Prefix}LambdaFunction    → AWS::Lambda::Function  (DependsOn: LogGroup)
{Prefix}LambdaVersion{Hash} → AWS::Lambda::Version (Ref: Function)

The prefix is the PascalCased function key from serverless.yml. SLS replaced - with Dash and _ with Underscore: - api_handlerApiUnderscorehandler - my-serviceMyDashservice

Replace with: sls.lambdaFnG('{Prefix}', ...)

Optional fourth resource if log forwarding was configured:

{Prefix}LogSubscriptionFilter → AWS::Logs::SubscriptionFilter
Replace with: logDestination parameter on sls.lambdaFnG()

The schedule pair

Always two resources:

{Prefix}EventsRuleSchedule1                    → AWS::Events::Rule (ScheduleExpression)
{Prefix}LambdaPermissionEventsRuleSchedule1    → AWS::Lambda::Permission (events.amazonaws.com)

Replace with: sls.scheduleEventG('{Prefix}', schedule, enabled, ruleName)

The SQS event source

Single resource with a name like {Prefix}EventSourceMappingSQS{QueueLogical}:

{Prefix}EventSourceMapping... → AWS::Lambda::EventSourceMapping
  EventSourceArn: {Fn::GetAtt: [QueueLogical, Arn]}
  FunctionName: {Fn::GetAtt: [{Prefix}LambdaFunction, Arn]}

Replace with: { logicalName: sls.sqsEventSource(functionLogical, queueLogical, batchSize) }

Remember: SLS auto-added sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes to the IAM role. These must be in extraStatements.

The REST API resource tree

A single ApiGateway::RestApi at the root, then a tree of ApiGateway::Resource entries linked by ParentId:

ApiGatewayRestApi
  └─ ApiGatewayResource{PathSegment}          (parent: RootResourceId)
       └─ ApiGatewayResource{PathSegment}{Seg} (parent: above)

Path parameters become {name}Var suffix: {id}IdVar, {proxy+}ProxyVar.

Replace with: sls.restApiG() + sls.apiResource() per segment, or sam.Api() which auto-generates the tree.

The API method + CORS pair

Per route, two resources:

ApiGatewayMethod{Path}{Method}   → AWS_PROXY integration to Lambda
ApiGatewayMethod{Path}Options    → MOCK integration (CORS preflight)

The CORS OPTIONS method's Access-Control-Allow-Methods header lists all real HTTP methods on that path resource (not all methods globally). So /items with GET and POST would have 'OPTIONS,GET,POST'.

Replace with: sls.apiMethod() + sls.corsOptions(path, [methods on this path])

The API key bundle

Three resources that always appear together:

ApiGatewayApiKey1        → AWS::ApiGateway::ApiKey
ApiGatewayUsagePlan      → AWS::ApiGateway::UsagePlan
ApiGatewayUsagePlanKey1  → AWS::ApiGateway::UsagePlanKey

All three DependOn the Deployment resource. Triggered by private: true on routes in serverless.yml.

The HTTP API cluster

Simpler than REST API — no resource tree, no CORS mocks:

HttpApi                              → AWS::ApiGatewayV2::Api (ProtocolType: HTTP)
HttpApiStage                         → AWS::ApiGatewayV2::Stage ($default, AutoDeploy)
HttpApiAuthorizer{Name}              → AWS::ApiGatewayV2::Authorizer (JWT)
HttpApiIntegration{FunctionPrefix}   → AWS::ApiGatewayV2::Integration (one per Lambda)
HttpApiRoute{Method}{Path}           → AWS::ApiGatewayV2::Route (one per event)
{Function}LambdaPermissionHttpApi    → AWS::Lambda::Permission

Replace with: sls.httpApiG() + sls.httpApiJwtAuthorizer() + sls.httpApiFnG(), or sam.HttpApi().

The deployment bucket

Present in almost every template. Two resources:

ServerlessDeploymentBucket        → AWS::S3::Bucket (AES256 encryption)
ServerlessDeploymentBucketPolicy  → AWS::S3::BucketPolicy (deny insecure transport)

Replace with: sls.deploymentBucketG (a constant, not a function call).

The IAM execution role

One per service, shared by all functions:

IamRoleLambdaExecution → AWS::IAM::Role
  AssumeRolePolicyDocument: allows lambda.amazonaws.com
  Policies: [{
    PolicyDocument: {
      Statement: [
        // STANDARD (always present, do not include in extraStatements):
        logs:CreateLogStream + logs:CreateLogGroup + logs:TagResource → scoped to service prefix
        logs:PutLogEvents → scoped to service prefix

        // EXTRA (include in extraStatements):
        everything else — ec2, dynamodb, s3, ssm, sqs, etc.
      ]
    }
  }]
  RoleName: {service}-{stage}-{region}-lambdaRole
  ManagedPolicyArns: [...optional, e.g. VPC access policy...]

Replace with: sls.iamRoleG(namePrefix, extraStatements, managedPolicies)

The base log permissions are generated automatically. Only pass the extra statements.