AWS SAM vs Vercel for an API
Let's compare deploying an API with AWS SAM vs Vercel.
Diagram of AWS SAM deployment (left) and Vercel Functions (right)
AWS SAM Attempt
Recently I set up a couple deployments using AWS Serverless Application Model (SAM) and to my chagrin it was as painful as I remembered it. My first problem was that, like any security-conscious developer I wanted to follow the principle of least privilege, running the deployment as a user with limited permissions. After many failed attempts, I finally created an IAM policy that included just the permissions I required, but it still felt very permissive and ran to 200 lines:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyPermissions",
"Effect": "Allow",
"Action": [
"lambda:GetAccountSettings",
"lambda:GetEventSourceMapping",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:GetFunctionCodeSigningConfig",
"lambda:GetFunctionConcurrency",
"lambda:ListEventSourceMappings",
"lambda:ListFunctions",
"lambda:ListTags",
"iam:ListRoles"
],
"Resource": "*"
},
{
"Sid": "DevelopFunctions",
"Effect": "Allow",
"NotAction": ["lambda:PutFunctionConcurrency"],
"Resource": "arn:aws:lambda:*:*:function:website-*"
},
{
"Sid": "DevelopEventSourceMappings",
"Effect": "Allow",
"Action": [
"lambda:DeleteEventSourceMapping",
"lambda:UpdateEventSourceMapping",
"lambda:CreateEventSourceMapping"
],
"Resource": "*",
"Condition": {
"StringLike": {
"lambda:FunctionArn": "arn:aws:lambda:*:*:function:website-*"
}
}
},
{
"Sid": "PassExecutionRole",
"Effect": "Allow",
"Action": [
"iam:AttachRolePolicy",
"iam:CreateRole",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:SimulatePrincipalPolicy",
"iam:TagRole",
"iam:DetachRolePolicy",
"iam:DeleteRolePolicy",
"iam:DeleteRole"
],
"Resource": ["arn:aws:iam::*:role/website-*", "arn:aws:iam::*:policy/website-*"]
},
{
"Sid": "ViewLogs",
"Effect": "Allow",
"Action": ["logs:*"],
"Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/website-*"
},
{
"Sid": "CodeDeployPermissions",
"Effect": "Allow",
"Action": [
"codedeploy:CreateApplication",
"codedeploy:DeleteApplication",
"codedeploy:RegisterApplicationRevision",
"codedeploy:GetApplicationRevision",
"codedeploy:GetApplication",
"codedeploy:GetDeploymentGroup",
"codedeploy:CreateDeploymentGroup",
"codedeploy:DeleteDeploymentGroup",
"codedeploy:CreateDeploymentConfig",
"codedeploy:GetDeployment",
"codedeploy:GetDeploymentConfig",
"codedeploy:RegisterOnPremisesInstance",
"codedeploy:ListApplications",
"codedeploy:ListDeploymentConfigs",
"codedeploy:ListDeploymentGroups",
"codedeploy:ListDeployments"
],
"Resource": "*"
},
{
"Sid": "CloudFormationPermissions",
"Effect": "Allow",
"Action": [
"cloudformation:CreateChangeSet",
"cloudformation:CreateStack",
"cloudformation:DeleteChangeSet",
"cloudformation:DeleteStack",
"cloudformation:DescribeChangeSet",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:ExecuteChangeSet",
"cloudformation:GetTemplateSummary",
"cloudformation:ListStackResources",
"cloudformation:SetStackPolicy",
"cloudformation:UpdateStack",
"cloudformation:UpdateTerminationProtection",
"cloudformation:GetTemplate",
"cloudformation:ValidateTemplate"
],
"Resource": [
"arn:aws:cloudformation:*:*:stack/website-*/*",
"arn:aws:cloudformation:*:*:transform/Serverless-2016-10-31"
]
},
{
"Sid": "S3Permissions",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:PutEncryptionConfiguration",
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:GetBucketLocation",
"s3:ListAllMyBuckets",
"s3:GetBucketLogging",
"s3:PutBucketLogging"
],
"Resource": ["arn:aws:s3:::website-*", "arn:aws:s3:::website-*/*"]
},
{
"Sid": "S3ReadPermissions",
"Effect": "Allow",
"Action": ["s3:GetBucketLocation", "s3:ListAllMyBuckets"],
"Resource": "*"
},
{
"Sid": "ApiGatewayPermissions",
"Effect": "Allow",
"Action": [
"apigateway:GET",
"apigateway:POST",
"apigateway:PUT",
"apigateway:DELETE",
"apigateway:PATCH"
],
"Resource": [
"arn:aws:apigateway:*::/restapis",
"arn:aws:apigateway:*::/restapis/*",
"arn:aws:apigateway:*::/tags/*"
]
},
{
"Sid": "AllowResourcePolicyUpdates",
"Effect": "Allow",
"Action": ["apigateway:UpdateRestApiPolicy"],
"Resource": ["arn:aws:apigateway:*::/restapis/*"]
},
{
"Sid": "CloudFrontPermissions",
"Effect": "Allow",
"Action": [
"cloudfront:CreateDistribution",
"cloudfront:GetDistribution",
"cloudfront:UpdateDistribution",
"cloudfront:DeleteDistribution",
"cloudfront:ListDistributions",
"cloudfront:TagResource",
"cloudfront:UntagResource",
"cloudfront:ListTagsForResource"
],
"Resource": "*"
},
{
"Sid": "EventBridgePermissions",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:DescribeRule",
"events:DeleteRule",
"events:PutTargets",
"events:RemoveTargets"
],
"Resource": "arn:aws:events:*:*:rule/website-*"
}
]
}
Next up were the SAM templates, a bit of a pain passing environment variables via samconfig, a lot of !Refs pointing things in the right direction, unclear AWS documentation, not fun to debug at all, and the final template also requiring 200 lines (template.yaml):
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
StageName:
Type: String
Default: dev
AllowedValues:
- dev
- qa
- prod
AcmCertificateArn:
Type: String
ElasticUseCloud:
Type: String
Description: Use Elastic Cloud
ElasticLocalNode:
Type: String
Description: Local Node
ElasticCloudId:
Type: String
Description: Elastic Cloud Id
ElasticCloudUsername:
Type: String
Description: Elastic Cloud Username
ElasticCloudPassword:
Type: String
Description: Elastic Cloud Password
ElasticIndexName:
Type: String
Description: Elastic Index Name
Highlight:
Type: String
Description: Highlight
Default: true
HighlightPreTag:
Type: String
Description: Highlight Pre Tag
Default: <strong>
HighlightPostTag:
Type: String
Description: Highlight Post Tag
Default: </strong>
DefaultPageSize:
Type: String
Description: Default Page Size
Default: 24
MaxPageSize:
Type: String
Description: Max Page Size
Default: 100
DefaultOptionsSize:
Type: String
Description: Default Options Size
Default: 20
Globals:
Function:
Tags:
Project: 'website-sam-search-api'
Stage: !Ref StageName
Resources:
ApiGatewayApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Ref StageName
Cors:
AllowMethods: "'GET,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization'"
AllowOrigin: "'*'"
WebsiteSearchApi:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub 'website-sam-search-api-${StageName}'
Handler: dist/index.handler
Runtime: nodejs20.x
Timeout: 5
Role: !GetAtt WebsiteSearchApiRole.Arn
Events:
Search:
Type: Api
Properties:
RestApiId: !Ref ApiGatewayApi
Path: /search
Method: get
Options:
Type: Api
Properties:
RestApiId: !Ref ApiGatewayApi
Path: /options
Method: get
SearchAsYouType:
Type: Api
Properties:
RestApiId: !Ref ApiGatewayApi
Path: /searchAsYouType
Method: get
HealthCheck:
Type: Api
Properties:
RestApiId: !Ref ApiGatewayApi
Path: /healthcheck
Method: get
Root:
Type: Api
Properties:
RestApiId: !Ref ApiGatewayApi
Path: /
Method: get
Environment:
Variables:
ELASTIC_USE_CLOUD: !Ref ElasticUseCloud
ELASTIC_LOCAL_NODE: !Ref ElasticLocalNode
ELASTIC_CLOUD_ID: !Ref ElasticCloudId
ELASTIC_CLOUD_USERNAME: !Ref ElasticCloudUsername
ELASTIC_CLOUD_PASSWORD: !Ref ElasticCloudPassword
ELASTIC_INDEX_NAME: !Ref ElasticIndexName
HIGHLIGHT: !Ref Highlight
HIGHLIGHT_PRE_TAG: !Ref HighlightPreTag
HIGHLIGHT_POST_TAG: !Ref HighlightPostTag
DEFAULT_PAGE_SIZE: !Ref DefaultPageSize
MAX_PAGE_SIZE: !Ref MaxPageSize
DEFAULT_OPTIONS_SIZE: !Ref DefaultOptionsSize
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: 'es2020'
Sourcemap: true
EntryPoints:
- src/index.ts
WebsiteSearchApiRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub 'website-sam-search-api-${StageName}-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- 'sts:AssumeRole'
Policies:
- PolicyName: !Sub 'website-sam-search-api-${StageName}-policy'
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- 'logs:CreateLogGroup'
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: 'arn:aws:logs:*:*:*'
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
HttpVersion: http2
DefaultCacheBehavior:
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
- OPTIONS
CachedMethods:
- GET
- HEAD
- OPTIONS
TargetOriginId: ApiGatewayOrigin
ForwardedValues:
QueryString: true
Headers:
- Origin
- Access-Control-Request-Headers
- Access-Control-Request-Method
- Authorization
- Content-Type
Cookies:
Forward: all
Origins:
- Id: ApiGatewayOrigin
DomainName: !Sub '${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com'
OriginPath: !Sub '/${StageName}'
CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: match-viewer
DefaultRootObject: index.html
Aliases:
- !Sub 'search-${StageName}.yourwebsite.com'
ViewerCertificate:
AcmCertificateArn: !Ref AcmCertificateArn
MinimumProtocolVersion: TLSv1.2_2021
SslSupportMethod: sni-only
Outputs:
CloudFrontDistributionDomainName:
Value: !GetAtt CloudFrontDistribution.DomainName
During this process I had quite a few CloudFormation stacks caught in bad rollback states. Frustratingly, I couldn’t figure out how to use a “fixed” CloudFront distribution. If I deleted a stack and re-ran the deploy, a new CloudFront distribution with a new ID would be created, requiring me to update the DNS CNAME to point to the new distribution.
Local development was painful, I couldn’t find a way to run sam locally with a watch/hot reloading option (see this old Github issue), so I had to manually run the build each change.
My biggest gripe is that I just don’t like the feeling of running the deploy, knowing all these AWS resources are being created everywhere… It all just feels too complex and fragile. I’m sure there’s a better, simpler way to deploy an API via AWS (maybe even using Amplify?), but by the time I finally had these 400 lines working I started reconsidering everything.
Enter Vercel
Yeah, we all know Vercel costs more, but look at how easy this is (vercel.json):
{
"framework": null,
"installCommand": null,
"buildCommand": "",
"outputDirectory": "public",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, OPTIONS"
},
{
"key": "Content-Type",
"value": "application/json"
}
]
}
]
}
The above sets up Vercel Serverless Functions with appropriate headers, providing similar functionality to the SAM template above.
Need to deploy a function that you only want to run on a schedule on the cron? Secure your function using CRON_SECRET and schedule it with just a couple lines of code (vercel.json):
{
"framework": null,
"installCommand": null,
"buildCommand": "",
"outputDirectory": "public",
"cleanUrls": true,
"trailingSlash": false,
"functions": {
"api/cron/sync.ts": {
"memory": 3009,
"maxDuration": 300
}
},
"crons": [
{
"path": "/api/cron/sync?type=collections&period=hour&quantity=2",
"schedule": "0 * * * *"
},
{
"path": "/api/cron/sync?type=web&period=hour&quantity=2",
"schedule": "30 * * * *"
}
]
}
Each function itself is defined in a file in the /api directory, for example this is the search endpoint (/api/search.ts):
import { search } from '../lib/search/search';
import type { ApiSearchResponse } from '../types';
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default async function handler(req: VercelRequest, res: VercelResponse): Promise<any> {
try {
const result: ApiSearchResponse = await search(req.query);
return res.status(200).json(result);
} catch (error) {
return res.status(500).json({ error: 'Internal server error' });
}
}
The Vercel deployment isn’t supremely configurable like the AWS SAM one, and it’s missing some features like rate limiting via a WAF (although Vercel has basic DDoS Mitigation and there’s a hack for rate-limiting using Vercel KV). But Vercel makes everything soooo easy, not only the configuration but also connecting environment variables and git branches to deployments, not to mention basic logging & monitoring.
For me, Vercel’s the clear winner here. I work in an organization that can barely afford developers, let alone devops, and I feel the lower cost of AWS must be balanced against the excellent DX, simplicity, and elegance of the Vercel deployment.