AWS SAM vs Vercel for an API
Let's compare deploying an API with AWS SAM vs Vercel.
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.