Most serverless teams deploy the same way they did on day one: serverless deploy from a pipeline, straight to production, all traffic at once. That works until the day a bad handler ships and 100% of requests start failing within seconds of the CloudFormation update completing. Lambda deploys are fast, which means bad Lambda deploys are fast too.
This tutorial covers the missing safety layer: weighted alias traffic shifting with AWS CodeDeploy, CloudWatch alarms that watch the new version, and automatic rollback — configured entirely from serverless.yml on Serverless Framework V4. We also cover the pieces CodeDeploy does not solve: asynchronous and event-source functions, database migrations, and feature flags.
What "canary" actually means for Lambda
There is no load balancer to drain in a serverless stack. The unit of traffic control is the Lambda alias.
- Every
serverless deploypublishes a new immutable version (myFunction:7). - An alias (
live) is a movable pointer to a version. - An alias can point at two versions at once with a weight: 90% to version 6, 10% to version 7.
CodeDeploy's Lambda deployment type does exactly one thing: it moves that weight over time, watches CloudWatch alarms while it does, and flips the alias back to the old version if an alarm fires. Everything else — API Gateway, EventBridge, SQS — must be wired to the alias, not to $LATEST, or none of this applies to real traffic.
Step 1: Install the plugin
serverless-plugin-canary-deployments generates the CodeDeploy application, deployment group, and alias wiring for you.
npm install --save-dev serverless-plugin-canary-deployments
service: checkout-api
frameworkVersion: '4'
plugins:
- serverless-plugin-canary-deployments
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-west-1
stage: ${opt:stage, 'dev'}
versionFunctions: true # required: canaries need published versions
versionFunctions: false is a common cost/clutter optimisation. It is incompatible with canary deployments — turn it back on for any service you want to roll out gradually.
Step 2: Add alarms worth rolling back on
Rollback is only as good as the alarm attached to it. Define alarms in resources so they live and die with the stack. The two that earn their keep are function errors and API 5xx.
resources:
Resources:
CheckoutErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: ${self:service}-${sls:stage}-checkout-errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref CheckoutLambdaFunction
- Name: Resource
Value: !Sub '${CheckoutLambdaFunction}:live'
Statistic: Sum
Period: 60
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
CheckoutLatencyAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: ${self:service}-${sls:stage}-checkout-p99
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref CheckoutLambdaFunction
- Name: Resource
Value: !Sub '${CheckoutLambdaFunction}:live'
ExtendedStatistic: p99
Period: 60
EvaluationPeriods: 2
Threshold: 2000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
Two details that trip people up:
- The
Resourcedimension with the:livequalifier is what scopes the metric to the alias. Without it you are alarming on the aggregate of all versions, and the 10% canary traffic gets drowned out by the healthy 90%. TreatMissingData: notBreaching. A low-traffic function emits noErrorsdatapoints when healthy;missingtreated as breaching will roll back every deploy at 3 a.m.
The logical ID CheckoutLambdaFunction is Serverless's generated name for a function called checkout — capitalise the function name and append LambdaFunction. Run serverless package and grep .serverless/cloudformation-template-update-stack.json if you are unsure.
Step 3: Declare the deployment settings
functions:
checkout:
handler: src/checkout.handler
timeout: 15
events:
- httpApi:
method: POST
path: /checkout
deploymentSettings:
type: Linear10PercentEvery1Minute
alias: live
preTrafficHook: preHook
postTrafficHook: postHook
alarms:
- CheckoutErrorsAlarm
- CheckoutLatencyAlarm
preHook:
handler: src/hooks.pre
timeout: 30
postHook:
handler: src/hooks.post
timeout: 30
The available type values map straight to CodeDeploy's built-in configurations:
| Type | Behaviour | Use for |
|---|---|---|
AllAtOnce | 100% immediately | dev/staging, low-risk internal tools |
Canary10Percent5Minutes | 10% for 5 min, then 100% | fast-moving APIs with good alarms |
Canary10Percent30Minutes | 10% for 30 min, then 100% | payment/checkout paths |
Linear10PercentEvery1Minute | +10% per minute (~10 min) | steady bake with decent traffic |
Linear10PercentEvery10Minutes | +10% per 10 min (~100 min) | low-traffic functions that need volume to trigger an alarm |
Pick the shape based on how long it takes your alarms to see enough traffic, not on how patient you feel. A function doing 3 requests per minute will never accumulate a meaningful error rate in a 5-minute, 10% canary — that is 1.5 requests hitting the new version. For low-traffic functions, either use a long linear rollout or skip the canary and invest in staging tests instead.
Stage-dependent settings keep dev fast:
custom:
deploymentType:
dev: AllAtOnce
staging: AllAtOnce
prod: Canary10Percent10Minutes
functions:
checkout:
deploymentSettings:
type: ${self:custom.deploymentType.${sls:stage}}
alias: live
alarms:
- CheckoutErrorsAlarm
Step 4: Write hooks that actually validate
Pre-traffic hooks run before any traffic shifts; post-traffic hooks run after the shift completes. Both must call back into CodeDeploy or the deployment hangs until it times out.
// src/hooks.js
import { CodeDeployClient, PutLifecycleEventHookExecutionStatusCommand }
from '@aws-sdk/client-codedeploy';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
const codedeploy = new CodeDeployClient({});
const lambda = new LambdaClient({});
async function report(event, status) {
await codedeploy.send(new PutLifecycleEventHookExecutionStatusCommand({
deploymentId: event.DeploymentId,
lifecycleEventHookExecutionId: event.LifecycleEventHookExecutionId,
status, // 'Succeeded' | 'Failed'
}));
}
export const pre = async (event) => {
try {
// Smoke-test the NEW version directly, before customers touch it.
const res = await lambda.send(new InvokeCommand({
FunctionName: `${process.env.CHECKOUT_FN}:${process.env.NEW_VERSION ?? '$LATEST'}`,
Payload: Buffer.from(JSON.stringify({
requestContext: { http: { method: 'POST', path: '/checkout' } },
body: JSON.stringify({ smokeTest: true, items: [] }),
})),
}));
const parsed = JSON.parse(Buffer.from(res.Payload).toString());
if (res.FunctionError || parsed.statusCode >= 500) throw new Error('smoke test failed');
await report(event, 'Succeeded');
} catch (err) {
console.error('preTrafficHook failed', err);
await report(event, 'Failed'); // deployment aborts, zero customer impact
}
};
export const post = async (event) => {
// Warm caches, purge a CDN path, ping a release channel, run a read-only check.
await report(event, 'Succeeded');
};
A failing pre-hook is the cheapest possible rollback: nothing shifted, nothing to undo. Make the smoke test hit a genuine dependency — a DynamoDB read, a Secrets Manager fetch — so misconfigured IAM or a missing SSM parameter is caught here rather than by your customers.
Give the hook functions permission to report back:
provider:
iam:
role:
statements:
- Effect: Allow
Action: codedeploy:PutLifecycleEventHookExecutionStatus
Resource: !Sub 'arn:aws:codedeploy:${AWS::Region}:${AWS::AccountId}:deploymentgroup:${self:service}-*/*'
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !Sub '${CheckoutLambdaFunction.Arn}:*'
Step 5: Verify traffic is really going through the alias
This is the step teams skip, and it silently disables the entire mechanism. After deploying:
aws lambda get-alias --function-name checkout-api-prod-checkout --name live
{
"AliasArn": "arn:aws:lambda:eu-west-1:1234:function:checkout-api-prod-checkout:live",
"FunctionVersion": "6",
"RoutingConfig": { "AdditionalVersionWeights": { "7": 0.1 } }
}
Then confirm the integration points at the alias, not $LATEST:
# HTTP API integration URI should end in ":live"
aws apigatewayv2 get-integrations --api-id abcd1234 \
--query 'Items[].IntegrationUri'
Watch a live rollout:
aws deploy list-deployments \
--application-name CodeDeployApp-checkout-api-prod \
--include-only-statuses InProgress
aws deploy get-deployment --deployment-id d-XXXXXXXX \
--query 'deploymentInfo.[status,errorInformation]'
What canaries do not cover
Event-source-mapped functions. SQS, Kinesis and DynamoDB Streams event source mappings point at a single function ARN — there is no weighted split. You can point the mapping at the alias so a rollback flips consumers back instantly, but you never get a partial exposure. Treat these as all-at-once and rely on DLQs, idempotency and the ability to replay.
Asynchronous invokes. EventBridge and S3 notifications can target the alias and will honour the weights, but failures surface minutes later in the DLQ rather than in the Errors metric window. Widen EvaluationPeriods accordingly, or alarm on DLQ ApproximateNumberOfMessagesVisible.
Database migrations. A rollback flips code, not schema. Any migration that ships alongside a canary must be backwards compatible with the old version for the duration of the bake: expand first (add nullable column, dual-write), deploy, then contract in a later release. This is the single most common cause of "the rollback made it worse".
Anything behind a feature flag. If the risky change is flag-gated, the canary is measuring code paths nobody is executing. Flags and canaries are complementary: canary the deploy, then ramp the flag separately.
Cost. Provisioned Concurrency is attached to a version or alias. During a canary you may be paying for provisioned capacity on both versions, and CodeDeploy plus the hook invocations add a small charge. Negligible for most, worth knowing if you provision heavily.
A rollout policy that survives contact with reality
The policy we recommend on most engagements:
- dev / staging:
AllAtOnce, no alarms. Speed matters more than safety. - prod, low-risk functions (internal reporting, admin endpoints):
AllAtOncewith alarms that page, not roll back. - prod, customer-facing sync APIs:
Canary10Percent10Minutes, errors + p99 latency alarms, pre-traffic smoke test. - prod, money-touching paths:
Canary10Percent30Minutes, plus a business metric alarm (orders per minute dropping below a floor) alongside the technical ones.
The business-metric alarm is the one that catches the deploy that returns HTTP 200 while silently dropping every order. Emit it as an EMF metric from your handler, alarm on it, and add it to the alarms list — CodeDeploy does not care where the metric came from.
Wrap-up
Canary deployments are one serverless.yml block and two CloudWatch alarms away, and they convert your worst deploy of the year from a full outage into a 10% blip that self-heals in under a minute. The work is not the plugin — it is choosing alarms that fire on real failure, keeping migrations backwards compatible, and verifying that traffic actually flows through the alias.
If you would like a second pair of eyes on your deployment pipeline, alarm thresholds or rollback strategy, our DevOps for Serverless Apps and Serverless Monitoring and Logging Solutions teams do exactly this. Get in touch and tell us what your last bad deploy cost you.