How to Build and Deploy GraphQL Server in AWS Lambda using nodejs and CloudFormation

In this tutorial, we build and deploy a GraphQL server to AWS Lambda and access it via API Gateway endpoint.

By Subash Adhikari · · 12 min read

Updated . Written in 2020. Commands, prices, and links in this piece may be out of date.

Share on X


AWS Lambda, GraphQL and Node.js logos

I've been building GraphQL APIs in a serverless environment for over 3 years now, and I can't imagine going back to RESTful APIs. Combine the power of GraphQL with the scalability of AWS Lambda and you've got a server that can handle an infinite amount of traffic.

In this tutorial, we build a GraphQL server, deploy it to AWS Lambda, and access it through an API Gateway endpoint. We use CloudFormation and the AWS CLI to deploy all the AWS resources and the application code.

What is covered#

  1. Build a GraphQL Server using Apollo
  2. Deploy GraphQL Server to Lambda
  3. Use API Gateway to proxy requests to Lambda
  4. Use CloudFormation to deploy application stack to AWS
  5. Set up Lambda for local development.

If you just want the code, the full source for the application is on Github.

What is GraphQL#

GraphQL is a query language for describing APIs with a strongly typed schema. A GraphQL server fulfils those queries using your existing data. Here are a few of its main advantages.

Query only what your application needs#

Unlike with REST APIs, GraphQL clients can ask for exactly what they need and nothing more. The server returns only what the client asked for.

GraphQL uses strongly typed system#

Because GraphQL is strongly typed, clients can introspect the entire schema. The API becomes clear documentation of what the server can do, and it surfaces errors during development.

Compose your queries in a single request#

With GraphQL, you can query multiple resources and get a combined response in a single request. Fewer requests means apps using GraphQL perform much faster.

What is AWS Lambda#

AWS Lambda is a compute service that lets you run your application code without managing any servers. AWS takes care of the overhead, like infrastructure, security, resources, the operating system, and patches, so developers can focus on building the application.

Let's get started.

Setting up the project#

Start by creating a project folder, then change into it and initialise a node project. I'm using node 10.x in these examples. You can install whichever node version you like with asdf.

bash
mkdir apollo-server-lambda-nodejs
cd apollo-server-lambda-nodejs
yarn init

Next, create a folder to hold all our source code.

bash
mkdir src

Finally, create an index file inside src. This will be the Lambda handler.

bash
cd src
touch index.js

Initialize node project

Add the following code to the index file.

javascript
exports.handler = async () => {
  return { body: 'Hello from Lambda' };
};

This is a very simple Lambda handler that returns Hello from Lambda when it's invoked. Let's deploy it to AWS Lambda first.

Package the application code#

Before we can deploy to Lambda, we need to zip up the application and upload it to an S3 bucket. We'll use the AWS CLI to create the bucket, so set it up now with this guide if you haven't already.

Create an S3 bucket for deploying code to Lambda. Bucket names are globally unique across all AWS Regions, so pick a unique one.

bash
aws s3 mb s3://lambda-deploy-asln

Create an archive of the application with the zip command, then check the files inside it.

bash
zip -rq dist-latest.zip src package.json
zipinfo dist-latest.zip

Copy the zip file to S3 with the AWS CLI.

bash
aws s3 cp dist-latest.zip s3://lambda-deploy-asln/dist-latest.zip

Finally, check that the file exists in S3.

bash
aws s3 ls s3://lambda-deploy-asln

Copy application package to S3

Now that the packaged application is in S3, we need to set up Lambda and API Gateway in AWS. In the next section, we use CloudFormation to create all the AWS resources we need.

Set up AWS lambda with API gateway proxy integration#

CloudFormation is an AWS service that lets us write infrastructure as code. It makes creating and managing our application resources very simple. Let's use it to define our stack.

Create a file named cloudformation.yml at the root of the project.

bash
touch cloudformation.yml

Add the following to cloudformation.yml.

markup--code
---
Description: GraphQL server on AWS lambda
 
Parameters:
  Version:
    Description: Application version number
    Type: String
 
  BucketName:
    Description: S3 bucket name where the source code lives
    Type: String
 
Resources:
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: !Ref BucketName
        S3Key: !Sub dist-${Version}.zip
      Handler: src/index.handler
      Description: GraphQL Apollo Server
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: nodejs10.x
      Timeout: 10
 
  LambdaExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Policies:
        - PolicyName: "LambdaFunctionPolicy"
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
            - Effect: Allow
              Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              Resource: "*"
 
  GraphQLApi:
    Type: 'AWS::ApiGateway::RestApi'
    Properties:
      Name: apollo-graphql-api
 
  GraphQLApiResource:
    Type: 'AWS::ApiGateway::Resource'
    Properties:
      ParentId: !GetAtt GraphQLApi.RootResourceId
      RestApiId: !Ref GraphQLApi
      PathPart: 'graphql'
 
  GraphQLApiMethod:
    Type: 'AWS::ApiGateway::Method'
    Properties:
      RestApiId: !Ref GraphQLApi
      ResourceId: !Ref GraphQLApiResource
      AuthorizationType: None
      HttpMethod: POST
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations
 
  GraphQLApiDeployment:
    Type: 'AWS::ApiGateway::Deployment'
    Properties:
      RestApiId: !Ref GraphQLApi
      StageName: v1
    DependsOn:
      - GraphQLApiResource
      - GraphQLApiMethod
 
  GraphQLApiPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: lambda:invokeFunction
      FunctionName: !GetAtt LambdaFunction.Arn
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GraphQLApi}/*
 
Outputs:
  ApiUrl:
    Description: Invoke url of API Gateway endpoint
    Value: !Sub https://${GraphQLApi}.execute-api.${AWS::Region}.amazonaws.com/v1/graphql

I know there's a lot going on in this template, so let's go through it step by step.

Template Parameters#

First, we define some parameters used in the template. We can pass these in as parameter overrides when we deploy the CloudFormation stack.

yaml
Description: GraphQL server on AWS lambda
 
Parameters:
  Version:
    Description: Application version number
    Type: String
 
  BucketName:
    Description: S3 bucket name where the source code lives
    Type: String

Lambda Function#

Next, we define the Lambda function and tell it where to pull the application code from. This is the same bucket we created earlier.

yaml
LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: !Ref BucketName
        S3Key: !Sub dist-${Version}.zip
      Handler: src/index.handler
      Description: GraphQL Apollo Server
      Role: !GetAtt LambdaExecutionRole.Arn
      Runtime: nodejs10.x
      Timeout: 10

We want the Lambda function to send application logs to AWS CloudWatch, and Lambda needs special permissions to write logs there. So we create a role that allows writing to CloudWatch and assign it to the function.

markup--code
LambdaExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Policies:
        - PolicyName: "LambdaFunctionPolicy"
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
            - Effect: Allow
              Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              Resource: "*"

API Gateway#

We also want an HTTP endpoint to invoke the Lambda function, and API Gateway can give us one. We can configure API Gateway to proxy every incoming request from the client to the Lambda function and send the Lambda's response back to the client.

First, we create an API Gateway RestApi.

yaml
GraphQLApi:
    Type: 'AWS::ApiGateway::RestApi'
    Properties:
      Name: apollo-graphql-api

Then we create an API Gateway Resource that accepts requests at /graphql.

yaml
GraphQLApiResource:
    Type: 'AWS::ApiGateway::Resource'
    Properties:
      ParentId: !GetAtt GraphQLApi.RootResourceId
      RestApiId: !Ref GraphQLApi
      PathPart: 'graphql'

Next, we create an API Gateway Method so the Resource accepts POST requests, and integrate it with Lambda.

yaml
GraphQLApiMethod:
    Type: 'AWS::ApiGateway::Method'
    Properties:
      RestApiId: !Ref GraphQLApi
      ResourceId: !Ref GraphQLApiResource
      AuthorizationType: None
      HttpMethod: POST
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaFunction.Arn}/invocations

Finally, we create an API Gateway Deployment, which deploys the API to the given stage.

yaml
GraphQLApiDeployment:
    Type: 'AWS::ApiGateway::Deployment'
    Properties:
      RestApiId: !Ref GraphQLApi
      StageName: v1
    DependsOn:
      - GraphQLApiResource
      - GraphQLApiMethod

Lambda / API Gateway permission#

At this point both the Lambda function and API Gateway are configured. But API Gateway needs special permission to invoke a Lambda function, so we grant it by creating a Lambda Permission resource.

markup--code
GraphQLApiPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: lambda:invokeFunction
      FunctionName: !GetAtt LambdaFunction.Arn
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GraphQLApi}/*

Finally, we export the API URL at the end of the template. We'll use this URL to call the Lambda.

markup--code
Outputs:
  ApiUrl:
    Description: Invoke url of API Gateway endpoint
    Value: !Sub https://${GraphQLApi}.execute-api.${AWS::Region}.amazonaws.com/v1/graphql

Deploy CloudFormation stack to AWS#

With the CloudFormation template ready, let's deploy it to AWS with the AWS CLI.

Run the following command. Make sure to change BucketName to the name of the bucket you created earlier.

bash
aws cloudformation deploy \
  --template-file ./cloudformation.yml \
  --stack-name apollo-server-lambda-nodejs \
  --parameter-overrides BucketName=lambda-deploy-asln Version=latest \
  --capabilities CAPABILITY_IAM

Terminal running aws cloudformation deploy for the apollo-server-lambda-nodejs stack

The stack might take a little while to deploy. Once it's done, the Lambda function is ready to take requests.

Verify API Gateway and Lambda are working as expected#

Now that the CloudFormation stack is deployed, let's check everything works. We need the API Gateway URL to send a request to the Lambda function, and this is where the API URL we exported in the template comes in handy.

Run this command to print the API URL.

bash
aws cloudformation describe-stacks \
--stack-name=apollo-server-lambda-nodejs \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text

Terminal running aws cloudformation describe-stacks to print the API URL, ending in /v1/graphql

Now call the API URL with curl. You should get "Hello from Lambda" back from the server.

markup--code
curl -d '{}' https://o55ybz0sc5.execute-api.us-east-1.amazonaws.com/v1/graphql

Terminal using curl to call the API Gateway endpoint, which returns Hello from Lambda

Add deploy script for easier deployment#

You might have noticed we ran a whole bunch of commands to package and deploy the application. Running all of them every time we deploy would get tedious fast, so let's add a bash script to simplify the workflow.

Create a bin directory at the root of the application and add a file named deploy.

bash
mkdir bin
touch bin/deploy

Before we can run the script, it needs the right file permissions. Set them with this command.

bash
chmod +x bin/deploy

Create deploy script

At this point, the directory structure should look like the screenshot below.

Current directory structure

Add the following code to the file.

markup--code
#!/bin/bash
 
set -euo pipefail
 
OUTPUT_DIR=dist
CURRENT_DIR=$(pwd)
ROOT_DIR="$( dirname "${BASH_SOURCE[0]}" )"/..
APP_VERSION=$(date +%s)
STACK_NAME=apollo-server-lambda-nodejs
 
cd $ROOT_DIR
 
echo "cleaning up old build.."
[ -d $OUTPUT_DIR ] && rm -rf $OUTPUT_DIR
 
mkdir dist
 
echo "zipping source code.."
zip -rq $OUTPUT_DIR/dist-$APP_VERSION.zip src node_modules package.json
 
echo "uploading source code to s3.."
aws s3 cp $OUTPUT_DIR/dist-$APP_VERSION.zip s3://$S3_BUCKET/dist-$APP_VERSION.zip
 
echo "deploying application.."
aws cloudformation deploy \
  --template-file $ROOT_DIR/cloudformation.yml \
  --stack-name $STACK_NAME \
  --parameter-overrides Version=$APP_VERSION BucketName=$S3_BUCKET \
  --capabilities CAPABILITY_IAM
 
# Get the api url from output of cloudformation stack
API_URL=$(
  aws cloudformation describe-stacks \
  --stack-name=$STACK_NAME \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
  --output text
)
 
echo -e "\n$API_URL"
 
cd $CURRENT_DIR

OK, let's break down what this script does.

We start by defining some variables. The archive is generated inside the dist directory, and the app version is set to the timestamp when the script runs. Using a timestamp makes sure the version number is always unique and incremental.

markup--code
#!/bin/bash
 
set -euo pipefail
 
OUTPUT_DIR=dist
CURRENT_DIR=$(pwd)
ROOT_DIR="$( dirname "${BASH_SOURCE[0]}" )"/..
APP_VERSION=$(date +%s)
STACK_NAME=apollo-server-lambda-nodejs

Then we clean up any old builds and create a fresh dist directory.

bash
echo "cleaning up old build.."
[ -d $OUTPUT_DIR ] && rm -rf $OUTPUT_DIR
 
mkdir dist

Next, we zip up the source code and its dependencies.

bash
echo "zipping source code.."
zip -rq $OUTPUT_DIR/dist-$APP_VERSION.zip src node_modules package.json

Then we copy the zip file to the S3 bucket.

bash
echo "uploading source code to s3.."
aws s3 cp $OUTPUT_DIR/dist-$APP_VERSION.zip s3://$S3_BUCKET/dist-$APP_VERSION.zip

Then we deploy the CloudFormation stack.

bash
echo "deploying application.."
aws cloudformation deploy \
  --template-file $ROOT_DIR/cloudformation.yml \
  --stack-name $STACK_NAME \
  --parameter-overrides Version=$APP_VERSION BucketName=$S3_BUCKET \
  --capabilities CAPABILITY_IAM

Finally, we query the CloudFormation stack for the API URL in its Outputs and print it to the console.

bash
# Get the api url from output of cloudformation stack
API_URL=$(
  aws cloudformation describe-stacks \
  --stack-name=$STACK_NAME \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
  --output text
)
 
echo -e "\n$API_URL"

Deploy to AWS using the deploy script#

Let's try a deployment with the script. It expects an S3_BUCKET variable in the environment. Run the following command to deploy. When the deployment succeeds, the script prints the API URL we can use to invoke the Lambda.

bash
S3_BUCKET=lambda-deploy-asln ./bin/deploy

Terminal running the deploy script, which uploads the build to S3, deploys the stack and prints the GraphQL endpoint URL

To make this even simpler, let's run it through yarn. Add the following to your package.json.

json
"scripts": {
  "deploy": "S3_BUCKET=lambda-deploy-asln ./bin/deploy"
}

From now on, we can just run yarn deploy to start a deployment.

Improve workflow with local Lambda and API Gateway#

We change the application code a lot while working on it. Right now, deploying to the AWS us-east-1 region takes me around 10 seconds on a 40Mb/s upload connection.

Deploy time grows as the application grows. Waiting 10 seconds or more just to find out I made a syntax error isn't productive at all.

Let's fix that by running the Lambda function locally and invoking it through a local API endpoint. AWS SAM CLI lets us do exactly that. Follow the instructions on this page to install it.

Once it's installed, run the following command from the root of the project.

bash
sam local start-api --template-file cloudformation.yml

Start local development server

The local endpoint is now available at http://localhost:3000, and we can use it to send requests to our local Lambda.

Open another terminal and run the following command to send a request. You should see the response from the local Lambda function.

markup--code
curl -d '{}' http://localhost:3000/graphql

Invoke local lambda function

Finally, add this line to the scripts section of package.json.

json
"dev": "sam local start-api --template-file cloudformation.yml"

From now on, yarn dev starts the dev server.

Set up GraphQL server in Lambda#

Enough talking. Let's jump into the code and build the GraphQL server.

Start by installing the dependencies. We're using Apollo Server, an open-source GraphQL server implementation, to build our server.

bash
yarn add apollo-server-lambda graphql

Replace the contents of src/index.js with the following code.

javascript
const { ApolloServer, gql } = require('apollo-server-lambda');
 
const typeDefs = gql`
  type Query {
    user: User
  }
 
  type User {
    id: ID
    name: String
  }
`;
 
const resolvers = {
  Query: {
    user: () => ({ id: 123, name: 'John Doe' })
  }
};
 
const server = new ApolloServer({ typeDefs, resolvers });
 
exports.handler = server.createHandler();

Here we define a schema with a User type and a user query, then a resolver for that query. To keep things simple, the resolver returns a hardcoded user. Finally, we create a GraphQL handler and export it.

To send queries to the GraphQL server, we need a GraphQL client. Insomnia is my favourite, but any GraphQL client will do.

Let's run a query to make sure the server works.

Create a new GraphQL request in Insomnia.

Create new GraphQL request

Configure GraphQL request

Add the following query to the body and send it to http://localhost:3000. If your dev server is still running, you should see this response from the GraphQL server.

Perform GraphQL request to Local Server

Now that everything works on the local server, run the following command to deploy the GraphQL server to AWS.

bash
yarn deploy

Deploy server to AWS

Once the deployment finishes, the API URL is printed in the console. Replace the URL in Insomnia with the API Gateway one and run the query again to see it resolve.

Perform GraphQL request to AWS Lambda

Summary#

Congratulations, you've deployed a GraphQL server to AWS Lambda using nothing but CloudFormation. The server takes GraphQL requests from the client and returns the right response. We also set up a local development environment without adding many dependencies.

If you liked this tutorial, please share it with your network.


Thanks for reading. You can also follow along on X or with RSS.