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.

Next
Next

gettfully