AWS Builder Center
Deploying Lambda Functions Safely: SAM, CodeDeploy, Canary Strategies, and Automatic Rollback

Deploying Lambda Functions Safely: SAM, CodeDeploy, Canary Strategies, and Automatic Rollback

Learn three ways to deploy AWS Lambda functions safely with gradual traffic shifting and automatic rollback: raw CodeDeploy for the mechanics, SAM DeploymentPreference for real-world automation, and CodePipeline V2's native Lambda deploy action. Covers versions, aliases, routing weights, canary and linear strategies, pre-traffic hooks, and CloudWatch alarm-based rollback.

Developer Advocate @ AgilityFeat & WebRTC.ventures
Lambda deployments are unlike anything else on AWS. No servers to manage, no agents to install, no files to be copied — deployment means publishing a new version of your function and shifting traffic to it. The risk: without safeguards, traffic shifts are instantaneous and all-or-nothing. One broken version = 100% of traffic affected. The solution is gradual traffic shifting with validation and automatic rollback.
In this post we'll take a look at three ways to do this: raw CodeDeploy, SAM DeploymentPreference, and CodePipeline V2's native Lambda deploy action.

Prerequisites

To follow along, you'll need:
  • AWS CLI v2  installed and configured with credentials that have permissions for Lambda, IAM, CodeDeploy, and CloudWatch
  • AWS SAM CLI  installed
  • An AWS account
  • Node.js 20+ (for the Lambda function and hook code)

Lambda Versions, Aliases, and Routing Weights

Before getting into deployment strategies, you need to understand three primitives.
Versions are immutable snapshots of your function's code and configuration. Once published, a version never changes. Each version gets a numeric identifier (1, 2, 3...) and its own ARN.
Aliases are named pointers to a version. For example, an alias called live might point to version 5. Consumers invoke the alias ARN instead of a specific version — this gives you a stable endpoint that you can retarget without updating callers.
Routing config is the mechanism that makes gradual deployments possible. An alias can split traffic between two versions using AdditionalVersionWeights. For example, you can route 90% of invocations to version 1 and 10% to version 2.
This is the primitive that CodeDeploy uses — it adjusts AdditionalVersionWeights over time, gradually shifting traffic from the old version to the new one.

Approach 1 — Raw CodeDeploy (Understanding the Mechanics)

Using CodeDeploy directly gives you full visibility into how Lambda traffic shifting works. This approach has more moving parts than SAM, but understanding it makes everything else click.

Setup

Start by creating an IAM execution role for the Lambda function. The role grants basic permissions (CloudWatch Logs) via the AWSLambdaBasicExecutionRole managed policy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Create the execution role — Lambda needs this to write logs
aws iam create-role \
--role-name lambda-deploy-lab-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'


# Attach the basic execution policy (CloudWatch Logs access)
aws iam attach-role-policy \
--role-name lambda-deploy-lab-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

# Store the role ARN for later use
ROLE_ARN=$(aws iam get-role --role-name lambda-deploy-lab-role \
--query 'Role.Arn' --output text)
Write a minimal Lambda handler that returns a version identifier in the response. This version field is what the pre-traffic hook will validate later:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
mkdir -p lambda-deploy-lab && cd lambda-deploy-lab

cat > index.mjs << 'EOF'
export const handler = async (event) => {
return {
statusCode: 200,
// The "version" field lets the pre-traffic hook verify which version is responding
body: JSON.stringify({ version: "1.0", message: "Hello from v1" })
};
};
EOF

# Bundle into a zip — Lambda requires deployment packages as archives
zip function.zip index.mjs
Create the function and immediately publish version 1. Publishing freezes the current code and config into an immutable snapshot:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Create the function from the zip bundle
aws lambda create-function \
--function-name deploy-lab-function \
--runtime nodejs20.x \
--handler index.handler \
--role $ROLE_ARN \
--zip-file fileb://function.zip

# Publish version 1 — creates an immutable snapshot of the current code
aws lambda publish-version \
--function-name deploy-lab-function \
--query 'Version' --output text
# Returns: 1
Create the live alias pointing to version 1. Consumers will invoke this alias — it provides a stable ARN that you retarget during deployments:
1
2
3
4
5
# Create alias "live" → version 1 (consumers invoke the alias, not a version number)
aws lambda create-alias \
--function-name deploy-lab-function \
--name live \
--function-version 1
Now let's test our alias:
1
2
3
4
5
# Invoke the function through the "live" alias to verify the setup
aws lambda invoke --function-name deploy-lab-function --qualifier live \
--output text /dev/stdout 2>/dev/null | jq .

# Expected: {"statusCode":200,"body":"{\"version\":\"1.0\",\"message\":\"Hello from v1\"}"}
Next, let's create the CodeDeploy resources. CodeDeploy needs its own service role (with permissions to modify Lambda aliases) and an application configured for the Lambda compute platform:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# CodeDeploy service role — allows CodeDeploy to manipulate Lambda aliases
aws iam create-role \
--role-name codedeploy-lambda-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "codedeploy.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'


# This managed policy grants: lambda:UpdateAlias, lambda:GetAlias,
# lambda:GetProvisionedConcurrencyConfig, cloudwatch:DescribeAlarms, sns:Publish,
# and lambda:InvokeFunction ONLY on functions matching "CodeDeployHook_*"
aws iam attach-role-policy \
--role-name codedeploy-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda

CODEDEPLOY_ROLE=$(aws iam get-role --role-name codedeploy-lambda-role \
--query 'Role.Arn' --output text)

# Create the CodeDeploy application — must specify Lambda as the compute platform
aws deploy create-application \
--application-name lambda-deploy-lab \
--compute-platform Lambda

# Create deployment group with canary strategy: 10% traffic for 5 minutes, then 100%
# Lambda deployments are always BLUE_GREEN with traffic control
aws deploy create-deployment-group \
--application-name lambda-deploy-lab \
--deployment-group-name canary-group \
--service-role-arn $CODEDEPLOY_ROLE \
--deployment-config-name CodeDeployDefault.LambdaCanary10Percent5Minutes \
--deployment-style deploymentType=BLUE_GREEN,deploymentOption=WITH_TRAFFIC_CONTROL

The AppSpec File for Lambda

The Lambda AppSpec has a different structure than EC2. It declares which function, alias, and versions are involved, plus optional hooks that reference other Lambda functions for validation.
1
2
3
4
5
6
7
8
9
10
11
12
13
# appspec.yaml
version: 0.0
Resources:
- deploy-lab-function:
Type: AWS::Lambda::Function
Properties:
Name: deploy-lab-function # The function to deploy
Alias: live # The alias CodeDeploy will shift traffic on
CurrentVersion: 1 # Where traffic is now
TargetVersion: 2 # Where traffic should go
Hooks:
# Reference a Lambda function by name — CodeDeploy invokes it before traffic shifts
- BeforeAllowTraffic: CodeDeployHook_deploy-lab-pretraffic
Key differences from EC2 AppSpec:
  • Resources names the function, alias, and both versions (current and target)
  • Hooks references Lambda function names — not script files on disk
  • Only two hook events exist: BeforeAllowTraffic and AfterAllowTraffic
Important: The AWSCodeDeployRoleForLambda managed policy only grants lambda:InvokeFunction on functions matching CodeDeployHook_*. Hook functions must use this prefix or you'll get a permissions error when CodeDeploy tries to invoke them.

The Pre-Traffic Hook Function

The hook is a separate Lambda function that CodeDeploy invokes before shifting any traffic. It must:
  1. Invoke the new version
  2. Validate the response
  3. Report success or failure back to CodeDeploy by calling PutLifecycleEventHookExecutionStatus
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
cat > pretraffic-hook.mjs << 'EOF'
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
import { CodeDeployClient, PutLifecycleEventHookExecutionStatusCommand } from "@aws-sdk/client-codedeploy";

const lambda = new LambdaClient();
const codedeploy = new CodeDeployClient();

export const handler = async (event) => {
// CodeDeploy passes these identifiers — needed to report back the hook result
const deploymentId = event.DeploymentId;
const lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId;

// Default to Failed — only set Succeeded if validation passes
let status = "Failed";

try {
console.log("Validating new version:", process.env.NEW_VERSION);

const result = await lambda.send(new InvokeCommand({
FunctionName: process.env.TARGET_FUNCTION,
InvocationType: "RequestResponse",
Qualifier: process.env.NEW_VERSION,
}));

const payload = JSON.parse(Buffer.from(result.Payload).toString());
const body = JSON.parse(payload.body);

// Validate — check that the response has the expected structure
if (payload.statusCode === 200 && body.version) {
console.log("Validation passed:", body);
status = "Succeeded";
} else {
console.error("Validation failed — unexpected response:", payload);
}
} catch (err) {
// Catches invocation errors (e.g., function doesn't exist, timeout)
console.error("Validation failed — invocation error:", err);
}

// Step 3: Report result to CodeDeploy — this determines whether traffic shifts proceed
await codedeploy.send(new PutLifecycleEventHookExecutionStatusCommand({
deploymentId,
lifecycleEventHookExecutionId,
status,
}));

return { statusCode: 200, body: status };
};
EOF

zip pretraffic-hook.zip pretraffic-hook.mjs
Deploy the hook function. It needs its own execution role with permissions to invoke the target Lambda function and to report lifecycle status back to CodeDeploy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Create execution role for the hook function
aws iam create-role \
--role-name lambda-hook-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'


# Basic execution permissions (CloudWatch Logs)
aws iam attach-role-policy \
--role-name lambda-hook-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

# The hook needs two additional permissions:
# 1. lambda:InvokeFunction — to call the new version and validate it
# 2. codedeploy:PutLifecycleEventHookExecutionStatus — to report pass/fail
aws iam put-role-policy \
--role-name lambda-hook-role \
--policy-name hook-permissions \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "codedeploy:PutLifecycleEventHookExecutionStatus",
"Resource": "*"
}
]
}'


# Save the Hook's function ARN for later
HOOK_ROLE_ARN=$(aws iam get-role --role-name lambda-hook-role \
--query 'Role.Arn' --output text)

# Create the hook function with a 60-second timeout (gives it time to invoke + validate)
aws lambda create-function \
--function-name CodeDeployHook_deploy-lab-pretraffic \
--runtime nodejs20.x \
--handler pretraffic-hook.handler \
--role $HOOK_ROLE_ARN \
--zip-file fileb://pretraffic-hook.zip \
--timeout 60 \
--environment "Variables={TARGET_FUNCTION=deploy-lab-function}"
If the hook reports Failed, CodeDeploy rolls back immediately — no traffic ever reaches the new version.

Deploy with Canary

Update the function code to return version 2.0, then publish it as a new immutable version:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Change the return version in the function
cat > index.mjs << 'EOF'
export const handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify({ version: "2.0", message: "Hello from v2" })
};
};
EOF

# ZIP the function
zip function.zip index.mjs

# Update the function's $LATEST code
aws lambda update-function-code \
--function-name deploy-lab-function \
--zip-file fileb://function.zip

# Wait for the update to propagate (required before publishing)
aws lambda wait function-updated --function-name deploy-lab-function

# Publish version 2 — this is the version CodeDeploy will shift traffic to.
# Capture the version number so we can point the hook at it.
NEW_VERSION=$(aws lambda publish-version \
--function-name deploy-lab-function \
--query 'Version' --output text)
echo "Published version: $NEW_VERSION" # 2
Point the hook at the version being deployed. This is the step that makes the hook validate the new version rather than whatever the alias currently points at:
1
2
3
4
5
6
7
# Tell the hook which version to validate during BeforeAllowTraffic
aws lambda update-function-configuration \
--function-name CodeDeployHook_deploy-lab-pretraffic \
--environment "Variables={TARGET_FUNCTION=deploy-lab-function,NEW_VERSION=$NEW_VERSION}"

# Wait for the config update to settle before deploying
aws lambda wait function-updated --function-name CodeDeployHook_deploy-lab-pretraffic
Trigger the deployment. For Lambda, CodeDeploy accepts the AppSpec as inline JSON content:
1
2
3
4
5
6
7
8
# Create the deployment — AppSpec is passed inline as JSON matching the structure shown above
DEPLOY_ID=$(aws deploy create-deployment \
--application-name lambda-deploy-lab \
--deployment-group-name canary-group \
--revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 0.0, \"Resources\": [{\"deploy-lab-function\": {\"Type\": \"AWS::Lambda::Function\", \"Properties\": {\"Name\": \"deploy-lab-function\", \"Alias\": \"live\", \"CurrentVersion\": \"1\", \"TargetVersion\": \"2\"}}}], \"Hooks\": [{\"BeforeAllowTraffic\": \"CodeDeployHook_deploy-lab-pretraffic\"}]}"}}' \
--query 'deploymentId' --output text)

echo "Deployment: $DEPLOY_ID"
Poll the deployment status until it completes. The canary phase lasts 5 minutes — during that time, 10% of traffic hits version 2:
1
2
3
4
5
6
7
8
9
# Poll deployment status every 10 seconds until it finishes
while true; do
STATUS=$(aws deploy get-deployment --deployment-id $DEPLOY_ID \
--query 'deploymentInfo.status' --output text)
echo "$(date +%H:%M:%S) $STATUS"
# Exit loop when deployment is no longer in progress
if [ "$STATUS" != "InProgress" ] && [ "$STATUS" != "Created" ]; then break; fi
sleep 10
done
During the canary phase, inspect the alias to see the traffic split. The RoutingConfig field shows how traffic is distributed between versions:
1
2
3
4
5
# Check the alias routing configuration — shows the weight split between versions
aws lambda get-alias \
--function-name deploy-lab-function \
--name live \
--query '{Version: FunctionVersion, RoutingConfig: RoutingConfig}'
You'll see AdditionalVersionWeights showing 10% routed to version 2. Invoke the function 20 times to observe the traffic split in action — roughly 18 calls will hit v1 and 2 will hit v2:
1
2
3
4
5
6
7
# Invoke 20 times via the alias and tally which version responds
for i in $(seq 1 20); do
aws lambda invoke --function-name deploy-lab-function --qualifier live \
/tmp/lambda-response.json > /dev/null 2>&1
jq -r '.body | fromjson | .version' /tmp/lambda-response.json
done | sort | uniq -c
# Expected: ~18 responses from "1.0", ~2 from "2.0"
After 5 minutes, the canary period completes and the alias shifts 100% to version 2.

Automatic Rollback

Safe deployments rely on two layers of protection, but they are not equals:
  1. Pre-traffic hooks (the primary gate) — validate the new version before any traffic shifts. If the hook fails, CodeDeploy rolls back immediately and no user ever touches the new version. This is the layer you control directly and can test deterministically.
  2. CloudWatch alarms (the backstop) — watch runtime metrics during traffic shifting. They catch problems the hook can't see: issues that only appear under real load, or non-functional regressions like latency, throttling, or memory pressure. They are essential, but they're reactive — they act after some traffic has already reached the new version.
We'll demonstrate the hook doing its job, then wire up the alarm as the supporting layer.

The hook catches a broken deploy

Deploy a version that's outright broken — it throws on every invocation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# A deliberately broken handler — every invocation throws
cat > index.mjs << 'EOF'
export const handler = async (event) => {
throw new Error("Intentionally broken for rollback demo");
};
EOF

zip function.zip index.mjs

# Update, wait, and publish version 3
aws lambda update-function-code --function-name deploy-lab-function --zip-file fileb://function.zip
aws lambda wait function-updated --function-name deploy-lab-function
NEW_VERSION=$(aws lambda publish-version --function-name deploy-lab-function \
--query 'Version' --output text)
echo "Published version: $NEW_VERSION" # 3

# Point the hook at v3 so it validates the new (broken) version
aws lambda update-function-configuration \
--function-name CodeDeployHook_deploy-lab-pretraffic \
--environment "Variables={TARGET_FUNCTION=deploy-lab-function,NEW_VERSION=$NEW_VERSION}"
aws lambda wait function-updated --function-name CodeDeployHook_deploy-lab-pretraffic
Trigger the deployment. During BeforeAllowTraffic, the hook invokes v3, the invocation throws, the hook reports Failed, and CodeDeploy aborts before shifting any traffic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Trigger deployment — the hook will invoke v3, it throws, the hook fails the deployment
DEPLOY_ID=$(aws deploy create-deployment \
--application-name lambda-deploy-lab \
--deployment-group-name canary-group \
--revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 0.0, \"Resources\": [{\"deploy-lab-function\": {\"Type\": \"AWS::Lambda::Function\", \"Properties\": {\"Name\": \"deploy-lab-function\", \"Alias\": \"live\", \"CurrentVersion\": \"2\", \"TargetVersion\": \"3\"}}}], \"Hooks\": [{\"BeforeAllowTraffic\": \"CodeDeployHook_deploy-lab-pretraffic\"}]}"}}' \
--query 'deploymentId' --output text)

echo "Deployment: $DEPLOY_ID"

# Poll — expect BeforeAllowTraffic to fail, then the deployment to roll back
while true; do
STATUS=$(aws deploy get-deployment --deployment-id $DEPLOY_ID \
--query 'deploymentInfo.status' --output text)
echo "$(date +%H:%M:%S) $STATUS"
if [ "$STATUS" != "InProgress" ] && [ "$STATUS" != "Created" ]; then break; fi
sleep 10
done
The deployment reaches Failed (or Stopped) within seconds — no five-minute canary wait, no traffic generator, no timing games. The live alias never leaves version 2. Confirm it:
1
2
3
# The alias should still point at version 2 — v3 never received any traffic
aws lambda get-alias --function-name deploy-lab-function --name live \
--query '{Version: FunctionVersion, RoutingConfig: RoutingConfig}'
This is the whole point of a pre-traffic hook: a broken version is caught and rejected before it can affect a single request. Because we corrected the hook to validate the new version (not the alias), it actually sees v3's failure — the original alias-invoking version would have validated the healthy v2 and waved this broken deploy straight through.

Wiring up the alarm as a backstop

The hook can only catch what a synthetic invocation reveals. Some failures — a latency regression, elevated error rates under concurrency, memory pressure — only show up once real traffic flows. That's what the CloudWatch alarm layer is for. You attach an alarm to the deployment group, and CodeDeploy rolls back if it fires during traffic shifting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Create an alarm on the function's error count, scoped to the live alias
aws cloudwatch put-metric-alarm \
--alarm-name deploy-lab-errors \
--namespace AWS/Lambda \
--metric-name Errors \
--statistic Sum \
--period 60 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--treat-missing-data notBreaching \
--dimensions Name=FunctionName,Value=deploy-lab-function Name=Resource,Value=deploy-lab-function:live

# Attach the alarm to the deployment group and enable auto-rollback
aws deploy update-deployment-group \
--application-name lambda-deploy-lab \
--current-deployment-group-name canary-group \
--alarm-configuration enabled=true,alarms=[{name=deploy-lab-errors}] \
--auto-rollback-configuration enabled=true,events=DEPLOYMENT_FAILURE,DEPLOYMENT_STOP_ON_ALARM
With this in place, a deployment that passes the hook but misbehaves under canary traffic will trip the alarm and roll back automatically.

Approach 2 — SAM DeploymentPreference (The Real-World Workflow)

Serverless Application Model (SAM) automates everything from the previous section:
  • AutoPublishAlias detects code changes, publishes a new version, and creates/updates the alias
  • DeploymentPreference creates the CodeDeploy application, deployment group, and triggers the deployment
  • Alarms attaches CloudWatch alarms to the deployment group
  • Hooks wires pre/post-traffic validation functions

Complete SAM Workflow

Create the project directory:
1
mkdir -p sam-deploy-lab/src && cd sam-deploy-lab
The SAM template below defines: the main function with AutoPublishAlias and DeploymentPreference, a pre-traffic hook function with the required CodeDeployHook_ prefix, and a CloudWatch alarm for runtime error detection:
template.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda safe deployment with SAM DeploymentPreference

Globals:
Function:
Runtime: nodejs20.x
Timeout: 10

Resources:
# Main function — AutoPublishAlias creates/updates the "live" alias on each deploy
MyFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: sam-deploy-lab-function
Handler: src/app.handler
AutoPublishAlias: live # SAM auto-publishes a new version and updates this alias
DeploymentPreference:
Type: Canary10Percent5Minutes # 10% traffic for 5 min, then 100%
Alarms:
- !Ref FunctionErrorsAlarm # Rollback if this alarm fires during deployment
Hooks:
PreTraffic: !Ref PreTrafficHook # Validate before any traffic shifts

# Hook function — must start with "CodeDeployHook_" for SAM's auto-generated IAM to work
PreTrafficHook:
Type: AWS::Serverless::Function
Properties:
FunctionName: CodeDeployHook_sam-deploy-lab-pretraffic
Handler: src/pretraffic.handler
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- codedeploy:PutLifecycleEventHookExecutionStatus
Resource: '*'
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource: !Sub '${MyFunction.Arn}:*'
- Effect: Allow
Action:
- lambda:ListVersionsByFunction # Resolve which version is being deployed
Resource: !GetAtt MyFunction.Arn
Environment:
Variables:
TARGET_FUNCTION: !Ref MyFunction # Resolves to the function name

# Alarm — triggers automatic rollback if errors occur during canary
FunctionErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: sam-deploy-lab-errors
Namespace: AWS/Lambda
MetricName: Errors
Statistic: Sum
Period: 60
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
Dimensions:
- Name: FunctionName
Value: !Ref MyFunction
- Name: Resource
Value: !Sub '${MyFunction}:live' # Scoped to the alias, not $LATEST
Note: SAM requires hook function names to start with CodeDeployHook_. This prefix is added to the IAM permissions that SAM's auto-generated CodeDeploy role uses.
Now let's create the Lambda main function:
src/app.mjs — the main function, identical to the v1 we used in the raw CodeDeploy section:
1
2
3
4
5
6
7
export const handler = async (event) => {
return {
statusCode: 200,
// Same version field the hook validates against
body: JSON.stringify({ version: "1.0", message: "Hello from SAM v1" }),
};
};
src/pretraffic.mjs — the hook function. In the raw section we passed the exact version number to the hook as an env var, because we controlled publish-version ourselves. Here SAM owns publishing, so we don't know the version number at template time. Instead the hook resolves the newest published version at runtime with ListVersionsByFunction and invokes that version (not the alias, which still points at the old version during BeforeAllowTraffic). The function name comes from an environment variable injected by SAM:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { LambdaClient, InvokeCommand, ListVersionsByFunctionCommand } from "@aws-sdk/client-lambda";
import { CodeDeployClient, PutLifecycleEventHookExecutionStatusCommand } from "@aws-sdk/client-codedeploy";

const lambda = new LambdaClient();
const codedeploy = new CodeDeployClient();

// Resolve the highest-numbered published version — that's the one being deployed
const getNewestVersion = async (functionName) => {
let versions = [];
let marker;
do {
const page = await lambda.send(new ListVersionsByFunctionCommand({
FunctionName: functionName,
Marker: marker,
}));
versions.push(...page.Versions);
marker = page.NextMarker;
} while (marker);

return versions
.map((v) => v.Version)
.filter((v) => v !== "$LATEST")
.map(Number)
.sort((a, b) => b - a)[0]
.toString();
};

export const handler = async (event) => {
// CodeDeploy passes these identifiers for reporting back
const deploymentId = event.DeploymentId;
const lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId;
let status = "Failed";

try {
// Function name comes from the env var set in template.yaml
const functionName = process.env.TARGET_FUNCTION;

// Invoke the NEW version directly — the alias still points at the old
// version during BeforeAllowTraffic, so validating the alias tests the
// wrong code.
const targetVersion = await getNewestVersion(functionName);
console.log("Validating new version:", targetVersion);

const result = await lambda.send(new InvokeCommand({
FunctionName: functionName,
InvocationType: "RequestResponse",
Qualifier: targetVersion,
}));

const payload = JSON.parse(Buffer.from(result.Payload).toString());
const body = JSON.parse(payload.body);

// Validate: check for expected response structure
if (payload.statusCode === 200 && body.version) {
console.log("Validation passed:", body);
status = "Succeeded";
} else {
console.error("Validation failed:", payload);
}
} catch (err) {
console.error("Hook invocation error:", err);
}

// Report pass/fail to CodeDeploy — determines whether traffic shift proceeds
await codedeploy.send(new PutLifecycleEventHookExecutionStatusCommand({
deploymentId,
lifecycleEventHookExecutionId,
status,
}));

return { statusCode: 200, body: status };
};

Deploy and Observe

Build and deploy the stack for the first time. The --guided flag walks you through configuration (stack name, region, IAM confirmation):
1
2
3
4
5
6
7
# Build resolves dependencies and prepares deployment artifacts
sam build

# First deploy uses --guided to set stack name, region, and confirm IAM resource creation
sam deploy --guided
# Stack name: sam-deploy-lab
# Confirm changes, allow IAM role creation
Now change the function code and redeploy. SAM detects the code change, publishes a new version, and triggers a CodeDeploy canary deployment automatically:
1
2
3
4
5
6
# Bump the version string in the handler
sed -i 's/1.0/2.0/' src/app.mjs
sed -i 's/v1/v2/' src/app.mjs

# Build and deploy — SAM handles version publishing and traffic shifting
sam build && sam deploy
SAM automatically publishes a new version, creates a CodeDeploy deployment, and starts the canary shift. You can observe the deployment in the CodeDeploy console.
The developer workflow is: change code → sam deploy → everything else is automated.

Available Deployment Types

Canary — shifts a percentage of traffic, waits, then shifts the rest:
  • Canary10Percent5Minutes
  • Canary10Percent10Minutes
  • Canary10Percent15Minutes
  • Canary10Percent30Minutes
1
2
3
4
5
6
7
flowchart LR
START["0%"] -->|"shift 10%"| CANARY["10% for 5 min"]
CANARY -->|"shift remaining"| DONE["100%"]

style START fill:#36f,color:#fff
style CANARY fill:#f93,color:#fff
style DONE fill:#4c9,color:#fff
Linear — shifts traffic in equal increments at regular intervals:
  • Linear10PercentEvery1Minute
  • Linear10PercentEvery2Minutes
  • Linear10PercentEvery3Minutes
  • Linear10PercentEvery10Minutes
1
2
3
4
5
flowchart LR
S0["0%"] --> S1["10%"] --> S2["20%"] --> S3["30%"] --> S9["..."] --> S10["100%"]

style S0 fill:#36f,color:#fff
style S10 fill:#4c9,color:#fff
AllAtOnce — shifts 100% immediately. No gradual deployment. Useful for non-production environments.
Custom — create a custom CodeDeploy deployment config and reference it by name in the Type field.

Approach 3 — CodePipeline V2 Lambda Deploy Action

CodePipeline V2 introduced a native Lambda deploy action (May 2025) that handles traffic shifting without a separate CodeDeploy application.
The action configuration supports:
  • FunctionName — the Lambda function to deploy
  • FunctionAlias — the alias to shift traffic on
  • DeployStrategy — AllAtOnce, Canary10Percent5Minutes, Linear10PercentEvery1Minute, etc.
  • Alarms — comma-separated CloudWatch alarm names for automatic rollback
  • PublishedTargetVersion — the version to deploy (or deploy from source artifact)
A CodePipeline V2 action declaration for Lambda deploy with canary traffic shifting. No AppSpec, no CodeDeploy application — the action handles everything internally:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Pipeline action — deploys to the "live" alias with canary strategy
# Rolls back automatically if either alarm fires during traffic shifting
- Name: Deploy
ActionTypeId:
Category: Deploy
Owner: AWS
Provider: Lambda
Version: '1'
Configuration:
FunctionName: my-function # Target Lambda function
FunctionAlias: live # Alias to shift traffic on
DeployStrategy: Canary10Percent5Minutes # Same strategies as CodeDeploy
Alarms: my-function-errors,my-function-throttles # Comma-separated alarm names
InputArtifacts:
- Name: SourceArtifact # Zip with function code
CodePipeline manages the traffic shifting directly. The action updates the function code from the source artifact, publishes a new version, and shifts traffic according to the strategy.
When to use this approach: You already have a CodePipeline V2 pipeline and want fewer moving parts. The pipeline owns the deployment lifecycle.
When to use SAM instead: You manage infrastructure as code and want the deployment strategy declared alongside your function definition. SAM keeps everything in one template.

Comparison: When to Use What

ApproachComplexityAutomation LevelBest For
Raw CodeDeployHigh — manual version publishing, AppSpec authoring, hook wiringLowUnderstanding mechanics, custom orchestration
SAM DeploymentPreferenceLow — ~10 lines of configHigh — auto-publishes, auto-deploysMost production workloads
CodePipeline V2 Lambda actionMedium — pipeline configMediumExisting CodePipeline V2 workflows

Clean Up

Remove all resources created during the lab. SAM resources are deleted with sam delete, and the raw CodeDeploy resources require individual cleanup:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Delete the SAM stack (removes function, hook, alarm, CodeDeploy resources)
sam delete --stack-name sam-deploy-lab

# Delete raw CodeDeploy resources from Approach 1
aws deploy delete-deployment-group \
--application-name lambda-deploy-lab \
--deployment-group-name canary-group
aws deploy delete-application --application-name lambda-deploy-lab

# Delete the CloudWatch alarm
aws cloudwatch delete-alarms --alarm-names deploy-lab-errors

# Delete Lambda functions
aws lambda delete-function --function-name deploy-lab-function
aws lambda delete-function --function-name CodeDeployHook_deploy-lab-pretraffic

# Delete IAM roles (must detach policies first)
aws iam detach-role-policy --role-name lambda-deploy-lab-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lambda-deploy-lab-role

aws iam detach-role-policy --role-name codedeploy-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda
aws iam delete-role --role-name codedeploy-lambda-role

aws iam delete-role-policy --role-name lambda-hook-role --policy-name hook-permissions
aws iam detach-role-policy --role-name lambda-hook-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lambda-hook-role

Conclusion

Lambda deployments are about controlling traffic flow between immutable versions. CodeDeploy provides the mechanics: canary/linear shifting, pre-traffic hooks, alarm-based rollback. SAM wraps CodeDeploy into a declarative experience — AutoPublishAlias + DeploymentPreference handles everything from version publishing to traffic shifting to automatic rollback.
The safety pattern has a primary gate and a backstop. The pre-traffic hook is the primary gate: it validates the new version functionally (can it handle a request?) before any traffic shifts, and it's deterministic — a broken version is rejected in seconds, and no user is ever exposed. The CloudWatch alarm is the backstop: it monitors runtime behavior during the canary window and catches what a synthetic probe can't (latency, throttling, errors that only appear under real load). The alarm is reactive and subject to evaluation lag, so lean on the hook first and treat the alarm as the safety net behind it.
Interested in automating the deployment of your AWS Lambda-based applications? Let's talk! 
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article