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.