Ejecting Serverless with Jsonnet¶
Contents¶
- Why leave — SAM, CDK, and what we actually need
- The migration path — how to port existing stacks
- The boilerplate problem — what SLS/SAM hide from you
- The Jsonnet approach — what the replacement looks like
- What the library wraps — mapping SLS/SAM concepts to helpers
- Examples — scheduled worker, REST API, SQS consumer, HTTP API
- Multi-stage from a single file
- Deploying code separately — update-function-code vs S3
- Comparison — source lines, output transparency, build deps
- How it works — object merging and custom abstractions
- Replacing SAM with sam.libsonnet — SAM-shaped input, plain CFN output
- Beyond serverless — resource factories, policy libraries, config-driven stacks
- Targeting SAM output — when to let SAM do the expansion
- Trade-offs — what you gain and lose
- Getting started
- Caveats — key ordering, maturity, AI authorship
Why leave¶
Serverless Framework v4 switched to a commercial license. Deployments now require a Serverless Inc. account, report telemetry to their dashboard, and organizations above a revenue threshold need a paid subscription. The open-source v3 branch is frozen — no security patches, no new runtime support.
If you have dozens or even hundreds of services in production, this means either paying per seat for a build tool that wraps CloudFormation, or migrating. Most teams start evaluating alternatives at this point.
Why not SAM?¶
AWS SAM is the natural first stop. It's AWS-native, open source, and
AWS::Serverless::Function is a clean abstraction. But SAM only provides a
handful of built-in resource types (Function, Api, HttpApi,
SimpleTable, and a few others). The moment you need something outside that
set — a custom IAM policy, an SQS queue with a DLQ, a CloudWatch alarm — you
drop back to plain CloudFormation YAML with no templating, no functions, and no
way to reduce repetition. SAM gives you abstractions for the easy parts but
leaves you on your own for everything else.
Why not CDK?¶
AWS CDK is popular but carries real costs. Even if you write your
infrastructure in Python or C#, CDK's core runs on Node.js — you still need a
Node runtime and npm dependencies in each development or CI environment that
synthesizes the application. cdk synth runs your code through jsii and Node to
emit CloudFormation JSON. TypeScript projects also need their TypeScript
toolchain. For teams whose Lambda functions are Python or C#, that additional
toolchain may be more machinery than they want in the deployment layer.
CDK also generates CloudFormation with machine-generated logical IDs (hashes
like MyFunctionServiceRole3AC7B20E), deeply nested metadata blocks, and
CDK-internal parameters that make the synthesized template nearly unreadable.
Reviewing a cdk diff in a pull request means staring at hundreds of lines of
machine-generated JSON that no human authored — you're trusting the abstraction
rather than reviewing the infrastructure.
There's also a migration problem: CDK normally generates logical IDs from its construct tree, so they will not automatically match an existing Serverless or SAM stack. CDK can target an existing named stack, and CloudFormation supports resource import and refactoring workflows, but preserving the identity of every existing resource takes explicit work. Moving stateful resources such as DynamoDB tables, SQS queues, and S3 buckets may require retention policies, imports, and careful ordering.
CDK shines when you're building from scratch with large, complex infrastructure. For an in-place migration whose main goal is to preserve an existing generated template, it may be a poor fit.
What we actually need¶
The Serverless Framework did two things:
- Boilerplate reduction — a 7-line function definition expanded to 120+ lines of CloudFormation JSON.
- Deploy orchestration — packaging code, uploading to S3, calling
cloudformation deploy.
Item 2 can be replaced by a deployment script, but that script then owns artifact hashing and uploads, change-set handling, failure behavior, and credentials. The question here is narrower: can we get the same boilerplate reduction without a framework, a transform, or a Node.js build step?
Jsonnet¶
Jsonnet is a data templating language from Google. It compiles to JSON. It has functions, imports, and object merging — enough to build a library of helpers that generate CloudFormation resources. The binary is a single ~2MB executable with no runtime dependencies. Compilation is instant. Jsonnet is already widely used for generating Kubernetes manifests (via Tanka and ksonnet) — templating large, repetitive JSON/YAML configurations is exactly what it was designed for, and CloudFormation is the same kind of problem. The official tutorial is a good starting point for learning the language.
Jsonnet gives you many of the same abstraction tools as CDK — functions, local
variables, imports, conditionals, array comprehensions, object merging — but
everything stays declarative. Declarative here means: data goes in, JSON comes
out, and nothing else happens. There are no side effects — no file writes, no
network calls, no mutable state. You define data, not procedures. The output of
a Jsonnet file is the template, not a side effect of running a program. Unlike
CDK, there is no node_modules directory, transitive dependency tree, or
cdk synth step. The Jsonnet binary is a small executable with no runtime
dependencies, and these templates compile in milliseconds.
The approach: write a Jsonnet library where each helper returns a flat object
of CloudFormation resources. Merge them with +. Run jsonnet to emit plain
JSON. Deploy with aws cloudformation deploy. No framework, no transform, no
node_modules.
The migration path¶
You don't need to rewrite anything from scratch. The Serverless Framework
already generated the CloudFormation for you — every sls deploy submitted a
JSON template to CloudFormation, and that template is still sitting in your
deployment bucket or in the CloudFormation console under the stack's Template
tab.
Much of the migration is structural, which makes it a reasonable job for an AI
coding agent with human review. A tool like Claude Code can read your existing
CloudFormation JSON, factor it into library calls, and help verify the output
against the original.
This repository includes a sample Claude Code skill for this workflow:
skills/exit-serverless-to-jsonnet/SKILL.md.
The steps:
-
Capture the current generated CloudFormation JSON for each service (
sls packagewrites it to.serverless/cloudformation-template-update-stack.json, or pull it from the deployed stack withaws cloudformation get-template --stack-name my-service-dev). -
Identify the repeating patterns. Every template has the same structure: deployment bucket, IAM execution role, the Lambda triplet (LogGroup + Function + Version) per function, event wiring (schedule rules, SQS mappings, API Gateway resource trees). These are the constructs presented in this article.
-
Factor out each pattern into a library call. The 120-line Lambda triplet becomes
sls.lambdaFnG(...). The 50-line CORS OPTIONS mock becomessls.corsOptions(...). Service-specific resources (DynamoDB tables, custom IAM statements) stay as inline Jsonnet objects. -
Verify by comparing canonical JSON rather than raw text:
diff <(jsonnet my-service.jsonnet | jq -S .) <(jq -S . captured-original.json). The resource structure should match — key names, types, and properties. -
Create, inspect, and only then execute a change set against the existing stack. One way is
aws cloudformation deploy --no-execute-changeset ..., followed byaws cloudformation describe-change-set --stack-name <stack> --change-set-name <name>using the change-set name returned bydeploy. If the logical IDs and resource properties match, the change set should be empty. Do not let the first comparison execute automatically.
The aim is to replace the source of the template without replacing the deployed infrastructure. That can reduce migration risk, but only if the generated logical IDs and properties really match. CloudFormation acts on every difference, so the reviewed change set—not the source-level resemblance—is the safety boundary.
This matters because moving resources between CloudFormation stacks is painful.
If a stack owns a DynamoDB table or an SQS queue, you can't just declare it in
a new stack — CloudFormation will try to create a new one, and the old stack
still holds the original. Migrating stateful resources across stacks requires
DeletionPolicy: Retain, manual imports, and careful ordering to avoid data
loss. CDK makes this worse: because it generates logical IDs from hashes of the
construct path, even renaming a construct or moving it in the tree changes the
logical ID, which CloudFormation interprets as "delete the old resource and
create a new one." The safest path is to keep your existing stacks and change
only how the template is generated — which is exactly what this approach does.
The same general approach can be used for SAM templates, but sam build does
not expand the SAM transform into its final raw resources. To compare against
the processed CloudFormation, retrieve the deployed stack template with
aws cloudformation get-template --template-stage Processed, then factor that
output into sam.libsonnet calls.
The boilerplate problem¶
A typical serverless.yml function definition:
functions:
worker:
handler: handler.cleanup
runtime: python3.12
memorySize: 512
timeout: 600
events:
- schedule: cron(0 3 * * ? *)
This 7-line declaration expands to ~120 lines of CloudFormation JSON:
AWS::Logs::LogGroup— CloudWatch log groupAWS::Lambda::Function— the function itselfAWS::Lambda::Version— immutable version snapshotAWS::Events::Rule— the cron scheduleAWS::Lambda::Permission— allows EventBridge to invoke the functionAWS::IAM::Role— execution role with log permissionsAWS::S3::Bucket+BucketPolicy— deployment artifact storage
SAM's AWS::Serverless::Function also expands into ordinary CloudFormation, but
the exact resources depend on its properties and events. A Lambda function is
always generated; versions, aliases, roles, permissions, and event resources
are conditional.
Both approaches hide the generated CloudFormation from you. When something goes wrong, you're debugging a template you didn't write.
The Jsonnet approach¶
Write a Jsonnet library where each helper returns a flat object of CloudFormation resources:
local cfn = import 'lib/cfn.libsonnet';
local sls = import 'lib/sls.libsonnet';
{
AWSTemplateFormatVersion: '2010-09-09',
Resources:
sls.deploymentBucketG
+ sls.iamRoleG('my-service-dev', [...])
+ sls.lambdaFnG('Worker', { FunctionName: 'my-service-dev-worker', Handler: '...', ... })
+ sls.scheduleEventG('Worker', 'cron(0 3 * * ? *)'),
}
Run jsonnet my-service.jsonnet and you get plain CloudFormation JSON — no
transform, no framework, no plugins. What you see is what deploys.
What the library wraps¶
Each helper maps to what SLS/SAM auto-generated from a high-level declaration:
| SLS / SAM concept | Library helper | CFN resources generated |
|---|---|---|
functions: entry |
sls.lambdaFnG(...) |
LogGroup + Function + Version |
events: - schedule: |
sls.scheduleEventG(...) |
Events::Rule + Lambda::Permission |
events: - sqs: |
sls.sqsEventSource(...) |
Lambda::EventSourceMapping |
events: - http: |
sls.apiMethod(...) + sls.corsOptions(...) |
Method + CORS OPTIONS mock |
events: - httpApi: |
sls.httpApiFnG(...) |
Integration + Routes + Permission |
provider.iamRoleStatements |
sls.iamRoleG(...) |
IAM::Role with scoped log perms |
provider.apiKeys |
(manual) | ApiKey + UsagePlan + UsagePlanKey |
| implicit | sls.deploymentBucketG |
S3::Bucket + BucketPolicy |
| SLS log-subscription plugin | sls.lambdaFnG(..., logDestination=...) |
Logs::SubscriptionFilter |
Examples¶
Each .jsonnet file has a matching .yaml file next to it containing the
expanded CloudFormation output, so you can see what the template generates
without installing Jsonnet.
1. Scheduled worker (54 lines → 8 resources)¶
A Lambda function triggered by a cron schedule. The simplest serverless pattern.
→ examples/scheduled-worker.jsonnet
2. REST API with CORS (103 lines → 25 resources)¶
A single Lambda function behind API Gateway v1 with five routes, CORS OPTIONS on each path, and API key authentication. This is where the compression is most dramatic — API Gateway v1 generates enormous amounts of boilerplate.
3. SQS consumer with DLQ (99 lines → 9 resources)¶
A Lambda function polling an SQS queue, with a dead-letter queue for failures
and ReservedConcurrentExecutions to limit parallelism.
4. HTTP API with JWT auth (84 lines → 16 resources)¶
API Gateway v2 (HTTP API) with a JWT authorizer. The modern alternative to REST API — no resource tree, no CORS mocks, auto-deploy stage.
→ examples/http-api-jwt.jsonnet
5. Parameterized worker (91 lines → 5 resources)¶
A VPC-attached Lambda with CloudFormation input parameters: stage selection
with AllowedValues, VPC/subnet IDs, an SSM parameter for secrets, and a
cross-stack export. Demonstrates cfn.param(), cfn.ssmParam(), and
cfn.exportOutput().
→ examples/parameterized-worker.jsonnet
6. Clean-room stack (~50 lines → 10 resources)¶
A production-shaped stack built with aws.libsonnet — no Serverless Framework
conventions, no fixed logical IDs. Lambda + DynamoDB table + S3 bucket + IAM
role + cross-account invoker role + log forwarding to Kinesis + SSM parameter
exports. Every resource name is explicit and visible in the source.
7. Internal ALB with target-group factory (231 lines → 18 resources)¶
Three node types (AppServer, JobRunner, Gateway) each need an internal ALB
with 1–4 target groups. A tgPair() factory produces a matching TargetGroup +
Listener per port, and internalAlb() assembles the full ALB + security group +
N pairs from a compact config. Optional S3 access-log attributes, HTTPS
certificate wiring, and a tgArns handle for plugging into ASGs.
→ examples/load-balancer.jsonnet
Multi-stage from a single file¶
Every example accepts stage as an external variable:
jsonnet --ext-str stage=dev examples/scheduled-worker.jsonnet > dev.json
jsonnet --ext-str stage=prod examples/scheduled-worker.jsonnet > prod.json
One source file generates all stages. No near-identical copies.
Deploying code separately¶
The Serverless Framework bundled code deployment with infrastructure: it zipped
your handler, uploaded it to S3, and pointed Code.S3Key at the new artifact
in the CloudFormation template. Every deploy was a full stack update, even if
only the function code changed.
Once you manage the template yourself, you can separate the two concerns:
- Infrastructure changes (new resources, IAM policy updates, environment
variable changes) go through
aws cloudformation deployas before. - Code-only changes use
aws lambda update-function-code --function-name my-service-dev-worker --zip-file fileb://package.zip— no CloudFormation involved, completes in seconds.
This can be faster for day-to-day development, but it deliberately creates CloudFormation drift: the deployed function code no longer corresponds to the artifact named in the stack template. Keep a record of the deployed code version and make rollback an explicit part of the code-deployment script. Do not merely overwrite an object at a fixed S3 key and expect CloudFormation to detect new contents; use content-addressed keys whenever CloudFormation manages the code update.
The trade-off: CloudFormation rollbacks won't roll back code changes made
outside the stack. In practice this rarely matters — if a code deploy breaks
something, you redeploy the previous version with update-function-code. But
if you need atomic infrastructure + code rollbacks (e.g. a new DynamoDB table
and the code that uses it must deploy or fail together), keep the S3-based
approach for those stacks.
Comparison¶
For a REST API with 5 routes:
| Approach | Source lines | What deploys | Build dependency |
|---|---|---|---|
| Raw CloudFormation JSON | ~800 | Exactly what you wrote | None |
| Serverless Framework v4 | ~45 | Generated CFN (opaque) | Node.js + SLS account |
| SAM template | ~110 | SAM transform output (opaque) | Python or Node.js |
| AWS CDK (TypeScript) | ~60 | Generated CFN (hashed IDs) | Node.js + npm install + cdk synth |
| Jsonnet + sls.libsonnet | ~100 | Exactly what you wrote | Single binary, no deps |
| Jsonnet + sam.libsonnet | ~80–97 | Exactly what you wrote | Single binary, no deps |
The Jsonnet source is comparable in size to SAM, but the output is plain
CloudFormation with no transform step. You can diff it, review it in PRs, and
cfn-lint it directly. The sam.libsonnet layer is even more concise because
it auto-generates the API Gateway resource tree from event declarations.
How it works¶
Every helper is a Jsonnet function that returns a plain object:
lambdaFnG('Worker', { FunctionName: 'svc-dev-worker', Handler: 'h.main', ... })
// returns:
// {
// WorkerLogGroup: { Type: 'AWS::Logs::LogGroup', ... },
// WorkerLambdaFunction: { Type: 'AWS::Lambda::Function', ... },
// WorkerLambdaVersion: { Type: 'AWS::Lambda::Version', ... },
// }
Functions ending in G (for "group") return { LogicalId: Resource, ... }
objects containing multiple resources — merge them with +. All other
functions return a single resource value, used as { LogicalId: sls.helper(...) }:
Resources:
sls.deploymentBucketG // S3 bucket + policy (fixed keys)
+ sls.iamRoleG(...) // IAM role (fixed key)
+ sls.lambdaFnG(...) // LogGroup + Function + Version
+ sls.scheduleEventG(...) // Events::Rule + Permission
+ {
OrderDLQ: sls.sqsQueue(...), // single resource — key is the CFN logical ID
OrderQueue: sls.sqsQueue(...),
}
The G functions expand a name prefix into multiple derived resource keys. Single-resource helpers return bare values, so the logical ID is visible in your source — matching the CloudFormation output. Each helper is independent, there's no hidden state, and the output is a flat resource map that CloudFormation consumes directly.
Custom abstractions¶
Because Jsonnet has functions, imports, and object merging, you can build the same kind of project-level abstractions that CDK users create with custom constructs. A common example is enforcing a standard set of tags on every resource:
local standardTags(service, stage) = {
Tags: [
{ Key: 'Service', Value: service },
{ Key: 'Stage', Value: stage },
{ Key: 'Team', Value: 'platform' },
{ Key: 'ManagedBy', Value: 'cloudformation' },
],
};
// Apply to every resource that supports tags:
lambdaFnG('Handler', { ..., Tags: standardTags(service, stage) })
Another example: CloudFormation intrinsic functions like Fn::GetAtt and
Fn::Sub are verbose when written inline. The library provides shorthands:
// These one-liners in cfn.libsonnet:
getAtt(logical, attr):: { 'Fn::GetAtt': [logical, attr] },
sub(str):: { 'Fn::Sub': str },
ref(logical):: { Ref: logical },
// Turn this:
Resource: [{ 'Fn::GetAtt': ['OrderQueue', 'Arn'] }],
// Into this:
Resource: [cfn.getAtt('OrderQueue', 'Arn')],
A more realistic example: your company wants every Lambda's CloudWatch logs forwarded to a central Kinesis Data Firehose for log aggregation. Instead of copy-pasting the subscription filter into every service, you put the pattern in a shared library as a mixin that you merge alongside the Lambda:
// lib/company.libsonnet — shared abstractions across all services
{
// Returns a SubscriptionFilter resource that forwards a Lambda's logs
// to the central Firehose. Merge with `+` next to sls.lambdaFnG().
logForwarding(logicalName, functionName, firehoseArn, roleArn):: {
[logicalName + 'LogSubscription']: {
Type: 'AWS::Logs::SubscriptionFilter',
DependsOn: logicalName + 'LogGroup',
Properties: {
LogGroupName: '/aws/lambda/' + functionName,
FilterPattern: '',
DestinationArn: firehoseArn,
RoleArn: roleArn,
},
},
},
}
Services compose it with +, just like any other resource block:
local co = import 'lib/company.libsonnet';
Resources:
sls.lambdaFnG('Worker', { FunctionName: fnName, Handler: '...', ... })
+ co.logForwarding('Worker', fnName, firehoseArn, logRoleArn)
+ sls.scheduleEventG('Worker', 'cron(0 3 * * ? *)'),
No wrapping, no override — sls.lambdaFnG stays untouched and the mixin is
just another object merged in. This is the same pattern CDK teams achieve with
custom constructs, but the abstraction is a pure data transformation: you can
print the output, diff it, and reason about it without running anything.
A simpler but equally useful pattern is a shared constants file — mapping human-readable names to AWS account numbers, region codes, and other magic strings that would otherwise be scattered across templates:
// lib/constants.libsonnet
{
regions: {
dub: 'eu-west-1',
fra: 'eu-central-1',
iad: 'us-east-1',
pdx: 'us-west-2',
},
accounts: {
dev: '111111111111',
staging: '222222222222',
prod: '333333333333',
},
firehose: {
logArn: 'arn:aws:firehose:eu-west-1:111111111111:deliverystream/central-logs',
roleArn: 'arn:aws:iam::111111111111:role/firehose-log-role',
},
}
Every service imports the same file. Account numbers never change, but
constants.accounts.prod is easier to review in a PR than a bare
12-digit number — and you can't typo it.
The abstraction boundary is relatively thin. CDK constructs that do not expose
a needed property may require an escape hatch or a lower-level CfnResource.
Here, every helper returns a plain object, so you can merge in a raw
CloudFormation block with + next to it. A poorly designed helper can still be
confusing or destructive, but high-level helpers and low-level resources use
the same data model and can coexist in the same Resources: object.
CDK also makes reorganization painful: moving a resource from one construct to another changes its logical ID (because the construct path is part of the hash), which CloudFormation treats as a delete-and-recreate. CDK has a "refactor" mode to handle this, but it's another thing to know about and get right. In Jsonnet, logical IDs are just object keys — you control them directly, and moving code between files or functions doesn't change anything in the output.
Replacing SAM with sam.libsonnet¶
If you're already using SAM templates (or thinking in SAM terms), there's a
second library — sam.libsonnet — that accepts SAM-shaped declarations and
expands them to raw CloudFormation through sls.libsonnet.
Why replace SAM?¶
SAM templates are concise. A function with five API routes is about 40 lines.
But the SAM transform is a black box: you submit a template with
AWS::Serverless::Function and CloudFormation expands it at deploy time.
sam build prepares artifacts and a build template, but does not show the final
raw resources produced by the transform. You can retrieve a deployed stack's
processed template from CloudFormation; until then, failures can involve
resources you did not declare directly.
SAM also doesn't support custom abstractions — there's no way to define reusable patterns like the company-wide log forwarding mixin shown earlier. You get the built-in resource types and nothing else.
sam.libsonnet performs the same expansion at build time. The developer writes
SAM-shaped input, but the output is plain CloudFormation — no Transform:
header, no deploy-time surprises.
How it works¶
SAM's AWS::Serverless::Function puts events inside the function as an
Events: map. The transform then generates the external resources (API Gateway
methods, EventSourceMappings, permissions). sam.libsonnet does the same:
local sam = import 'lib/sam.libsonnet';
local sls = import 'lib/sls.libsonnet';
local fn = sam.Function('Api', {
Handler: 'app.handler',
CodeUri: 's3://my-bucket/package.zip',
Events: {
GetItems: { Type: 'Api', Properties: { Path: '/items', Method: 'get' } },
PostItems: { Type: 'Api', Properties: { Path: '/items', Method: 'post' } },
},
});
local api = sam.Api('MyApi', {
StageName: 'dev',
}, functions=[fn], service='my-service');
{
AWSTemplateFormatVersion: '2010-09-09',
Resources:
sls.iamRoleG('my-service-dev', fn.extraStatements)
+ fn.resources // LogGroup + Function + Version
+ api.resources, // RestApi + Resources + Methods + CORS + Deployment
}
The sam.Function() call returns:
- .resources — the Lambda triplet (and any non-API event resources)
- .extraStatements — IAM statements extracted from Policies: (pass to sls.iamRoleG)
- .apiEvents / .httpApiEvents — collected for the API expander
The sam.Api() call consumes those collected events and generates the full API
Gateway resource tree, CORS OPTIONS mocks, deployment, and permissions — the
same expansion the SAM transform would perform.
SAM Globals¶
SAM's Globals: section provides defaults merged into every function. The
Jsonnet equivalent is a local object you pass to each sam.Function():
local globals = {
Function: {
Runtime: 'python3.12',
Timeout: 300,
Tags: { Team: 'platform', Service: 'my-service' },
Environment: { Variables: { STAGE: stage } },
},
};
local fn = sam.Function('Api', { ... }, globals=globals.Function);
Tags and environment variables are merged (function-level overrides Globals). Runtime, timeout, and memory use function-level if set, otherwise fall back to Globals.
SAM examples¶
These produce the same CloudFormation output as the direct sls.libsonnet examples, but with SAM-style input:
| Example | Lines | Resources |
|---|---|---|
sam-rest-api.jsonnet — REST API + CORS + API key |
97 | 28 |
sam-http-api-jwt.jsonnet — HTTP API v2 + JWT |
80 | 16 |
sam-sqs-worker.jsonnet — SQS consumer + DLQ |
81 | 9 |
Mapping SAM resource types to library calls¶
| SAM resource type | sam.libsonnet call |
What it expands to |
|---|---|---|
AWS::Serverless::Function |
sam.Function(name, props, globals) |
LogGroup + Function + Version + event resources |
AWS::Serverless::Api |
sam.Api(name, props, functions) |
RestApi + Resource tree + Methods + CORS + Deployment + ApiKey |
AWS::Serverless::HttpApi |
sam.HttpApi(name, props, functions) |
ApiGatewayV2::Api + Stage + Authorizers + Integration + Routes |
Globals: |
Plain Jsonnet object passed to Function() |
Merged into each function's defaults |
Events: { Type: Schedule } |
Expanded inside Function() |
Events::Rule + Lambda::Permission |
Events: { Type: SQS } |
Expanded inside Function() |
Lambda::EventSourceMapping |
Events: { Type: Api } |
Collected, expanded by Api() |
Method + CORS OPTIONS + Permission |
Events: { Type: HttpApi } |
Collected, expanded by HttpApi() |
Route + Integration + Permission |
Libraries¶
Four libraries, pick the level that fits:
cfn.libsonnet— general-purpose CloudFormation helpers: intrinsic function shorthands (getAtt,getArn,sub,ref,arn), IAM policy builders (allow,deny,policies), parameter and output helpers, SSM parameter exports, and tag conversion.aws.libsonnet— clean-room resource helpers with no SLS/SAM conventions:lambdaG,lambdaRole,serviceRole,assumableRole,table,bucket,logSubscription,scheduleG. No fixed logical IDs — you pick every resource name.sls.libsonnet— serverless resource builders that match Serverless Framework naming:lambdaFnG,iamRoleG,deploymentBucketG, API Gateway, SQS, and schedule event wiring. Use for migrating existing SLS stacks in-place.sam.libsonnet— high-level SAM-shaped interface. Events are declared inline, API resource trees are generated automatically. Callssls.libsonnetunder the hood.
For new projects, start with cfn + aws. For migrating existing Serverless
Framework stacks, use cfn + sls (or sam) to preserve logical IDs.
All produce the same plain CloudFormation JSON.
Beyond serverless — greenfield CloudFormation¶
This article started as an SLS migration guide, but the same approach works
for any CloudFormation stack. The cfn.libsonnet and aws.libsonnet libraries
have no Serverless Framework conventions — they're general-purpose helpers for
writing CloudFormation as Jsonnet. Once you have functions, imports, and object
merging, the patterns that emerge go well beyond Lambda and API Gateway.
Resource factories¶
The most common pattern: a function that takes a config object and returns a
bundle of related resources. The caller gets back a handle with Refs and
GetAtts for wiring into other resources.
-
Internal ALB —
internalAlb()produces ALB + security group + N target-group/listener pairs from a port list. Three node types with different port configurations use the same factory. →load-balancer.jsonnet(231 lines → 18 resources) -
Static site —
staticSite()produces S3 bucket + OAC + CloudFront distribution + CloudFront Function + bucket policy. An array of site configs is iterated withstd.foldlto merge all resources. →static-site.jsonnet(289 lines → 15 resources) -
Queue with DLQ —
queueWithDlq()creates an SQS queue + dead-letter queue pair with consistent naming and redrive policy. →sample-patterns.jsonnet(278 lines → 23 resources)
IAM policy libraries¶
IAM policies are the worst offender for copy-paste in CloudFormation. The same
kms:Decrypt + kms:GenerateDataKey block shows up on every role that touches
an encrypted resource, differing only in the key ARN. A policy library turns
these into composable functions:
local workerStatements =
policies.ddbWrite(tableName)
+ policies.s3ReadWrite(bucketName)
+ policies.kmsViaService('s3')
+ policies.sqsConsume(inboundQueue.queueArn)
+ policies.ssmRead('/' + service + '/' + stage + '/*');
Each function returns an array of IAM policy statements. Compose them with +
and pass the result to a role builder. See the policy library and ECR
repository factory in
sample-patterns.jsonnet.
Config-driven encryption¶
KMS keys with service-specific grants follow a rigid structure — the key
resource, an alias, and a grant policy that varies by consumer. A factory
function parameterized by service name and principal ARNs eliminates the
repetition.
→ kms-keys.jsonnet (152 lines → 12 resources)
Deny-all-except bucket policies¶
A locked-down S3 bucket that denies all access except from a whitelist of IAM
roles is a common compliance pattern. The interesting part is generating the
StringNotLike condition from an array of role ARNs.
→ protected-bucket.jsonnet (191 lines → 6 resources)
Step Functions¶
State machine definitions are deeply nested JSON that benefits from Jsonnet's
ability to build objects from smaller pieces. A helper library
(sfn.libsonnet) provides shorthand for common states, and the machine
definition composes them with +.
→ step-functions.jsonnet (245 lines → 7 resources)
Starting a greenfield project¶
For a new project that has nothing to migrate:
- Copy
lib/cfn.libsonnetandlib/aws.libsonnetinto your repo - Copy
lib/cfn-actions.libsonnetif you want pre-defined IAM action lists - Write
.jsonnetfiles importing these libraries - Add project-specific factories as local functions or in a
lib/file
There's no framework to initialize, no bootstrap step, and no convention to
follow beyond "functions return objects, merge with +." Start with inline
local functions. Extract to a shared .libsonnet file when the same pattern
appears in a second stack.
Targeting SAM output¶
Everything above assumes Jsonnet replaces SAM by expanding to raw
CloudFormation. But Jsonnet can also produce SAM templates — just emit
Transform: 'AWS::Serverless-2016-10-31' and use AWS::Serverless::Function
directly. This makes sense when your existing SAM template already works and
the complexity is elsewhere: duplicated IAM policies across multiple roles,
repeated VPC/tag/environment blocks across functions, or derived values that
are awkward to thread through CloudFormation !Sub and !Ref. Jsonnet handles
composition and deduplication; SAM handles Lambda packaging and the
sam deploy workflow. The output is JSON, and sam deploy --template-file
accepts JSON the same as YAML. If you prefer YAML for readability or
diff-friendliness, a one-liner in your build script converts the output:
jsonnet template.jsonnet | python3 -c 'import sys,json,yaml; yaml.dump(json.load(sys.stdin),sys.stdout,default_flow_style=False)'.
This is the pragmatic choice when the SAM transform is earning its keep and you don't want
to rewrite working infrastructure just to avoid it.
Trade-offs¶
What you gain:
- No commercial license, no telemetry, no vendor account
- No Node.js runtime in your CI pipeline
- No deploy-time transform — what you commit is what CloudFormation sees
- Output is diffable, lintable (cfn-lint), and reviewable in PRs
- Multi-stage is a one-liner (--ext-str stage=prod)
- Build step is instant — jsonnet compiles in milliseconds, not seconds
- Library is ~800 lines of Jsonnet you can read and modify in an afternoon
What you lose:
- No plugin ecosystem (SLS) or policy templates (SAM)
- You maintain the library yourself (but it's small)
- Logical ID naming requires care when updating existing stacks in-place
- No sls deploy or sam deploy — you write a deploy script around
aws cloudformation deploy (which is what those tools call anyway)
Getting started¶
- Install Jsonnet:
brew install jsonnet/apt install jsonnet - Copy
lib/cfn.libsonnetandlib/sls.libsonnetinto your project (andlib/sam.libsonnetif you want the SAM-style interface, orlib/aws.libsonnetfor clean-room templates with no SLS naming conventions) - Write a
.jsonnetfile importing the library - Render:
jsonnet --ext-str stage=dev my-service.jsonnet > template.json - Validate:
aws cloudformation validate-template --template-body file://template.json - Package:
aws cloudformation package --template-file template.json --s3-bucket my-deploy-bucket --output-template-file packaged.json(uploads Lambda code referenced by local paths, rewritesCodeto S3 URIs) - Deploy:
aws cloudformation deploy --template-file packaged.json --stack-name my-service-dev --capabilities CAPABILITY_NAMED_IAM
Caveats¶
Key ordering. The Go implementation of Jsonnet (jsonnet-go, which is what
brew install go-jsonnet and most package managers ship) sorts object keys
alphabetically by default. This means your output JSON will have Resources
before AWSTemplateFormatVersion, and properties within each resource will be
reordered. CloudFormation doesn't care about key order, but it makes the output
harder to diff against hand-written templates or the original Serverless/SAM
output, and reduces the readability of the generated JSON.
I maintain a fork of jsonnet-go that adds a --preserve-field-order flag.
Pre-built binaries are available at
github.com/vivainio/go-jsonnet-fork v0.22.1-fork.1.
With this flag, keys appear in the order you wrote them in the .jsonnet file,
which makes diffs cleaner and the output easier to read. I plan to propose this
for upstream after more usage.
Not yet battle-tested. This approach has not been used for any production
serverless stack yet. The library and examples are functional but unproven at
scale. Any loss of resources or data resulting from deploying these templates is
your responsibility. Create the change set without executing it—for example
with aws cloudformation deploy --no-execute-changeset—inspect it with
aws cloudformation describe-change-set --stack-name <stack>
--change-set-name <name>, and execute it separately only after review.
AI-authored content. All prose, library code, and examples in this repository were authored by Claude Opus 4.6 and reviewed by a (presumed) human.
Related writing¶
- What Cloudflare's Free Tier Gives Vibe Coders looks at a much smaller cloud stack where the deployment model is part of the appeal.
- My Vibing Flow describes the agent-assisted working style behind experiments like this one.