How to create IAM roles for deploying your AWS Serverless app

AWSDevOpsIAM

Getting IAM permissions right is one of the hardest parts about building serverless applications on AWS.

Many official tutorials and blog posts cop out of giving you the full details on how to set up IAM, preferring something vague like “ensure you use least-privilege permissions when creating this role”. Or worse, they give you a wide open wildcard or admin-level example policy with a “don’t use this in production” warning. Not very helpful either way.

(To be fair to these authors, production-hardened IAM is difficult and often context-dependent, and it would probably double the length of their article just to explain it.)

I find that the high-level concept of least-privilege is pretty simple to grasp for most engineers—grant an actor in your system just the amount of permissions it needs to do its job and no more. Simple to understand but difficult and time-consuming to implement well.

In the context of serverless applications, there are two general categories of action to which IAM permissions can be applied. I call them run-time and deploy-time. Run-time actions are those that your running application performs, e.g. the AWS services that a Lambda function calls on to when it is invoked. Deploy-time actions are those which deploy the resources and their associated configuration to an AWS account, e.g. creating a DynamoDB table, S3 bucket, Lambda function, etc.

In this article, I’m focusing solely on deploy-time actions, specifically those which deploy resources to AWS via CloudFormation, or an abstraction over CloudFormation (Serverless Framework, CDK, SAM, etc).

In this article, you’ll learn:

  1. What IAM roles you need to create, along with canned role definitions you can use as your own starting point.
  2. How each IAM role interacts within the deployment process.
  3. Why creating a separate role for CloudFormation ensures your CD deployments are more secure.
  4. Recommended practices to follow when defining your role policy statements.
  5. How to use your IAM deployment roles in a multi-account AWS environment.
  6. How to assume your IAM deployer role from a CodeBuild project.
  7. How to assume your IAM deployer role from GitHub Actions (or any third-party CI/CD provider).
  8. Using dedicated IAM role for running post-deployment tests.
  9. Useful tools and further reading you can use to dig deeper.

The two IAM roles you need

Before we talk about specific permissions, let’s look at the two IAM roles you will need to create and how they work together: DeployerRole and CloudFormationExecutionRole. Both of these roles must be created inside the account which is the target of the deployment.

Overview of the IAM roles used in the deployment of a CloudFormation stack

Both of these roles should be created within a standalone CloudFormation stack which is independent of (and a prerequisite to) your application workload stacks.

Now let’s walk through each role.

DeployerRole

This role is assumed by the principal who is initiating the deployment process. This will typically be a CI/CD service such as a CodeBuild container or GitHub Actions workflow runner. The CI/CD script will execute a CLI command which will run under the context of this role when making any calls to the AWS API, e.g. sls deploy for the Serverless Framework or sam deploy for the AWS SAM CLI.

These deployment framework CLIs make AWS API calls for the following actions:

  • Fetching configuration data required to synthesise a new CloudFormation template (e.g. from SSM Parameter Store or other CloudFormation stack exports)
  • Creating an S3 bucket for storing deployment state artifacts and metadata within it
  • Validating a CloudFormation template that it has just synthesised
  • Deploying the CloudFormation template

Once it is ready to deploy the CloudFormation template, the deployment framework can tell the CloudFormation service to assume a specific role—the CloudFormationExecutionRole–when provisioning the resources defined with the template.

In the Serverless Framework, this can be done via the following setting:

# serverless.yml
provider:
  iam:
    deploymentRole:
	  Fn::Sub: 'arn:aws:iam::${AWS::AccountId}:role/MyApp-CloudFormationExecutionRole'

Here’s an example of the policies inside the DeployerRole for a pipeline that uses the Serverless Framework to deploy the app resources:

# `DeployerRole` policies
Policies:
  - PolicyName: DelegateToCloudFormationRole
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Action:
            - iam:PassRole
          Resource:
            - !GetAtt CloudFormationExecutionRole.Arn
          Effect: Allow
  - PolicyName: ServerlessFrameworkCli
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Sid: ValidateCloudFormation
          Effect: Allow
          Action:
            - cloudformation:ValidateTemplate
          Resource: '*'
        - Sid: ExecuteCloudFormation
          Effect: Allow
          Action:
            - cloudformation:CreateChangeSet
            - cloudformation:CreateStack
            - cloudformation:DeleteChangeSet
            - cloudformation:DeleteStack
            - cloudformation:DescribeChangeSet
            - cloudformation:DescribeStackEvents
            - cloudformation:DescribeStackResource
            - cloudformation:DescribeStackResources
            - cloudformation:DescribeStacks
            - cloudformation:ExecuteChangeSet
            - cloudformation:ListStackResources
            - cloudformation:SetStackPolicy
            - cloudformation:UpdateStack
            - cloudformation:UpdateTerminationProtection
            - cloudformation:GetTemplate
          Resource:
            - !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AppId}-*/*'
        - Sid: ReadLambda
          Effect: Allow
          Action:
            - lambda:Get*
            - lambda:List*
          Resource:
            - '*'
        - Sid: ManageSlsDeploymentBucket
          Effect: Allow
          Action:
            - s3:CreateBucket
            - s3:DeleteBucket
            - s3:ListBucket
            - s3:PutObject
            - s3:GetObject
            - s3:DeleteObject
            - s3:GetBucketPolicy
            - s3:PutBucketPolicy
            - s3:DeleteBucketPolicy
            - s3:PutBucketAcl
            - s3:GetEncryptionConfiguration
            - s3:PutEncryptionConfiguration
          Resource:
            - !Sub 'arn:aws:s3:::${AppId}-*'
        - Sid: ListS3
          Effect: Allow
          Action:
            - s3:List*
          Resource: '*'

A few things to note here:

  • the iam:PassRole permission is used to allow the role to delegate to CloudFormationExecutionRole
  • the ServerlessFrameworkCli inline policy defines statements for the different operations the CLI (and its plugins) might need to make
  • I’ve used an ${AppId}-* prefix on the Resource values for the CloudFormation stacks and S3 bucket. AppId is supplied as a parameter to the CloudFormation template and should be set to the unique name of your application. This naming convention ensures that only application-level stacks (which should all start with this prefix) can be modified by the app and any baseline landing zone stacks (e.g. for auditing) won’t be accessible to this role. Note that AppId should not contain the deployment stage within it (dev, staging, prod, etc) because certain deployment frameworks (e.g. the Serverless Framework) append this stage to the very end of the stack and resource names it autogenerates (e.g. myapp-restapi-dev instead of myapp-dev-restapi).

CloudFormationExecutionRole

The CloudFormationExecutionRole is where the permissions for deploying the application-specific resources are defined.

You could get away without creating the CloudFormationExecutionRole and instead have CloudFormation assume the DeployerRole and define all your permissions within it. However, the deployment itself requires high-privilege permissions which could also be highly destructive (creating, updating and deleting DynamoDB tables and S3 buckets, and data within them).

By separating these out into another role, we ensure they can only be executed by the CloudFormation service, which is inherently a more secure environment than a CodeBuild or GitHub Actions container. This is done by way of the AssumePolicyDocument which defines which principals are allowed to assume this role:

AssumeRolePolicyDocument:
  Version: 2012-10-17
  Statement:
    - Effect: Allow
      Principal:
        Service: cloudformation.amazonaws.com
      Action: sts:AssumeRole

Determining deploy-time permissions

So now we need to decide what permissions to grant the CloudFormationExecutionRole. These permissions will vary depending on your application architecture and there will inevitably be some trial and error involved in getting this permission set right:

  1. Deploy IAM role
  2. Deploy stack
  3. If stack deploy fails, check error message in CloudFormation and update role definition with new permissions
  4. Go back to 1

Since most serverless apps use a few common services, I’ve included a good starting point below for the policies that you can attach to the CloudFormationExecutionRole. I’ve grouped each target service into its own policy (e.g. DeployLambdaFunctions) which should make it easier for you to remove what you don’t need.

# `CloudFormationExecutionRole` policies
ManagedPolicyArns:
  - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
Policies:
  - PolicyName: DeployLambdaFunctions
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - lambda:Get*
            - lambda:List*
            - lambda:CreateFunction
            - lambda:DeleteFunction
            - lambda:CreateFunction
            - lambda:DeleteFunction
            - lambda:UpdateFunctionConfiguration
            - lambda:UpdateFunctionCode
            - lambda:PublishVersion
            - lambda:CreateAlias
            - lambda:DeleteAlias
            - lambda:UpdateAlias
            - lambda:AddPermission
            - lambda:RemovePermission
            - lambda:InvokeFunction
          Resource:
            - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AppId}-*'
  - PolicyName: DeployLogGroups
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - logs:CreateLogGroup
            - logs:Get*
            - logs:Describe*
            - logs:List*
            - logs:DeleteLogGroup
            - logs:PutResourcePolicy
            - logs:DeleteResourcePolicy
            - logs:PutRetentionPolicy
            - logs:DeleteRetentionPolicy
            - logs:TagLogGroup
            - logs:UntagLogGroup
          Resource:
            - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AppId}-*'
            - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/http-api/${AppId}-*'
        - Effect: Allow
          Action:
            - logs:Describe*
          Resource:
            - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*'
        - Effect: Allow
          Action:
            - logs:CreateLogDelivery
            - logs:DeleteLogDelivery
            - logs:DescribeResourcePolicies
            - logs:DescribeLogGroups
          Resource:
            - '*'
  - PolicyName: DeployAppBuckets
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Sid: AllBucketPermissions
          Effect: Allow
          Action:
            - s3:ListAllMyBuckets
            - s3:ListBucket
          Resource: '*'
        - Sid: WriteAppBuckets
          Effect: Allow
          Action:
            - s3:Get*
            - s3:List*
            - s3:CreateBucket
            - s3:DeleteBucket
            - s3:PutObject
            - s3:DeleteObject
            - s3:PutBucketPolicy
            - s3:DeleteBucketPolicy
            - s3:PutEncryptionConfiguration
          Resource:
            - !Sub 'arn:aws:s3:::${AppId}-*'
  - PolicyName: DeployCloudFront
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - cloudfront:Get*
            - cloudfront:List*
            - cloudfront:CreateDistribution
            - cloudfront:UpdateDistribution
            - cloudfront:DeleteDistribution
            - cloudfront:TagResource
            - cloudfront:UntagResource
          Resource:
            - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:*/*'
        - Sid: DeployCloudFrontOriginAccessIdentity
          Effect: Allow
          Action:
            - cloudfront:CreateCloudFrontOriginAccessIdentity
            - cloudfront:UpdateCloudFrontOriginAccessIdentity
            - cloudfront:GetCloudFrontOriginAccessIdentity
            - cloudfront:GetCloudFrontOriginAccessIdentityConfig
            - cloudfront:DeleteCloudFrontOriginAccessIdentity
            - cloudfront:ListCloudFrontOriginAccessIdentities
          Resource: '*'
  - PolicyName: DeployLambdaExecutionRoles
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - iam:Get*
            - iam:List*
            - iam:PassRole
            - iam:CreateRole
            - iam:DeleteRole
            - iam:AttachRolePolicy
            - iam:DeleteRolePolicy
            - iam:PutRolePolicy
            - iam:TagRole
            - iam:UntagRole
          Resource:
            - !Sub 'arn:aws:iam::${AWS::AccountId}:role/${AppId}-*'
  - PolicyName: DeployAPIGateway
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - apigateway:GET
            - apigateway:POST
            - apigateway:PUT
            - apigateway:PATCH
            - apigateway:DELETE
          Resource:
            - !Sub 'arn:aws:apigateway:${AWS::Region}::/apis'
            - !Sub 'arn:aws:apigateway:${AWS::Region}::/apis/*'
  - PolicyName: DeployEventBridge
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - events:Describe*
            - events:Get*
            - events:List*
            - events:CreateEventBus
            - events:DeleteEventBus
            - events:PutRule
            - events:DeleteRule
            - events:PutTargets
            - events:RemoveTargets
            - events:TagResource
            - events:UntagResource
          Resource:
            - !Sub 'arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/${AppId}-*'
            - !Sub 'arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/${AppId}-*'
  - PolicyName: DeploySNSTopics
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - sns:Get*
            - sns:Describe*
            - sns:CreateTopic
            - sns:DeleteTopic
            - sns:SetTopicAttributes
            - sns:Subscribe
            - sns:Unsubscribe
            - sns:TagResource
          Resource:
            - !Sub 'arn:aws:sns:${AWS::Region}:${AWS::AccountId}:${AppId}-*'
  - PolicyName: DeployDynamoDB
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - dynamodb:CreateTable
            - dynamodb:CreateTableReplica
            - dynamodb:CreateGlobalTable
            - dynamodb:DeleteTable
            - dynamodb:DeleteGlobalTable
            - dynamodb:DeleteTableReplica
            - dynamodb:Describe*
            - dynamodb:List*
            - dynamodb:Get*
            - dynamodb:TagResource
            - dynamodb:UntagResource
            - dynamodb:UpdateContinuousBackups
            - dynamodb:UpdateGlobalTable
            - dynamodb:UpdateGlobalTableSettings
            - dynamodb:UpdateTable
            - dynamodb:UpdateTableReplicaAutoScaling
            - dynamodb:UpdateTimeToLive
          Resource:
            - !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${AppId}-*'
            - !Sub 'arn:aws:dynamodb::${AWS::AccountId}:global-table/${AppId}-*'
  - PolicyName: DeploySQS
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - sqs:CreateQueue
            - sqs:DeleteQueue
            - sqs:SetQueueAttributes
            - sqs:AddPermission
            - sqs:RemovePermission
            - sqs:TagQueue
            - sqs:UntagQueue
            - sqs:Get*
            - sqs:List*
          Resource:
            - !Sub 'arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${AppId}-*'
  - PolicyName: DeploySSMParameterStore
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - ssm:GetParameter*
            - ssm:DescribeParameters
            - ssm:DeleteParameter*
            - ssm:PutParameter
          Resource:
            - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AppId}'
            - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AppId}/*'

Guidelines on permission definitions

Here are a few guidelines to follow when specifying the CloudFormationExecutionRole permissions:

  • Use a resource prefix pattern where possible (e.g. a short name for your application) to constrain the resources to which the permissions are granted. This works well for most AWS services where the resource ARN is predictable and can be controlled by IaC configuration. However, this isn’t possible with some AWS resources whose ARNs are completely dynamic (e.g. CloudFront distributions and AppSync API endpoints).
  • Certain actions aren’t specific to a resource (e.g. cloudformation:ValidateTemplate) and so you must set Resource: '*' when declaring these. This feels dirty but is totally fine and does not mean you’re being overly permissive.
  • Always grant the equivalent Update and Remove/Delete actions to the Create actions. While your stack may deploy successfully the first time (when every action is a create), it may fail whenever an update is required or a resource is removed from the stack and requires deletion. For example, if granting lambda:CreateFunction, ensure you also grant lambda:UpdateFunction* and lambda:DeleteFunction for the same resource pattern.

Handling cross-account deployments

AWS recommends that you maintain separate accounts for different environments for an application/workload (dev, staging, prod, etc). If you have a CI/CD pipeline that deploys across these stages, how do you handle this?

Firstly, all the above recommendations about creating the two IAM roles remain. These roles should be created in every target account.

Tip: Use OrgFormation to centrally manage the definitions of these IAM roles via Infrastructure-as-Code, and apply them to all target accounts within your AWS Organization via a single CLI command.

In addition to the deployment target accounts, AWS recommends creating a shared “Tools” account to hold resources such as CodePipeline, CodeBuild and any other resources required to support delivery of releases.

The diagram below gives an overview of how each IAM entity is linked inside a GitHub Actions workflow:

The IAM entities involved in a cross-account Continuous Deployment pipeline using GitHub Actions

Trusting the Tools account from the target accounts

The first step in setting up cross-account deployment, which applies no matter if you’re using CodeBuild or a third party provider, is to instruct the DeployerRoles in the target accounts to allow an IAM principal within the Tools account to assume its role.

This is done by setting the following AssumeRolePolicyDocument for the DeployerRole:

AssumeRolePolicyDocument:
  Version: 2012-10-17
  Statement:
    - Effect: Allow
      Principal:
        AWS:
          # pick one of the following
          - !Sub 'arn:aws:iam::${ToolsAccountId}:role/${AppId}-CodeBuildRole'
          - !Sub 'arn:aws:iam::${ToolsAccountId}:user/${AppId}-GitHubActionsUser'
      Action: sts:AssumeRole

The value you choose for the the principal here will depend upon on whether you’re using CodeBuild or GitHub Actions.

Assuming IAM deployer role from CodeBuild

If you are using a CodeBuild project to deploy your serverless app, this project will be configured to run using a dedicated IAM role defined in the Tools account. In order to assume the DeployerRole in the target accounts, your CodeBuild role needs to be granted access via the sts:AssumeRole action.

The following policy statement can be applied inline in your CodeBuild role to grant it access to the DeployerRole in the dev, staging and prod accounts.

Statement:
  - Sid: assumeCrossAccountRoles
    Action:
      - sts:AssumeRole
    Resource:
      - !Sub 'arn:aws:iam::${DevAccountId}:role/${AppId}-DeployerRole'
      - !Sub 'arn:aws:iam::${StagingAccountId}:role/${AppId}-DeployerRole'
      - !Sub 'arn:aws:iam::${ProdAccountId}:role/${AppId}-DeployerRole'
    Effect: Allow

Assuming IAM deployer role from CodeBuild from GitHub Actions or third party Continuous Deployment provider

If you’re using GitHub Actions for your Continuous Deployment, the approach is slightly different. IAM roles need to be assumed by an IAM principal. A principal can be an AWS service or an IAM user. For the CodeBuild example, we already had a principal capable of assuming the role (CodeBuild itself), but we don’t for third party services. Therefore, the current method AWS recommend for connecting to it is to create a dedicated IAM user (see here for more info).

In a multi-account AWS environment, this IAM user should be created in the Tools account. This means that the CD provider only needs to store one set of credentials, not a set per target account.

The IAM user should have the same policy statement attached to it as specified above for the CodeBuild role. This policy could be attached inline within the IAM user or alternatively (my preferred approach) define it as a standalone managed policy as follows:

GithubActionsCrossAccountPolicy:
  Type: 'AWS::IAM::ManagedPolicy'
  Properties:
    ManagedPolicyName: !Sub '${AppId}-GithubActionsCrossAccountPolicy'
    Description: Policy for user to assume the GitHub Actions cross account role in target accounts.
    Path: '/managed/'
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action:
            - sts:AssumeRole
          Resource:
            - !Sub 'arn:aws:iam::${DevAccountId}:role/${AppId}-DeployerRole'
            - !Sub 'arn:aws:iam::${StagingAccountId}:role/${AppId}-DeployerRole'
            - !Sub 'arn:aws:iam::${ProdAccountId}:role/${AppId}-DeployerRole'
        - Effect: Allow
          Action:
            - sts:TagSession
          Resource: '*'

The managed policy approach means that the policy definition provisioning can be managed independently of the IAM user creation.

Once the IAM user and policy are set up, the IAM user credentials can be stored inside GitHub Actions encrypted secrets and the user can be used in the workflow.

The following GHA workflow uses a composite steps action named sls-deploy (defined beneath it) to deploy a Serverless Framework service through three environments:

# Main Workflow File
name: main-svc-RestApi

# Triggers the workflow on commits to main branch
on:
  push:
    branches:
      - main

env:
  SERVICE_FOLDER: services/rest-api
  NODEJS_VERSION: 14.x

jobs:
  deploy-test:
    name: MainPipeline-RestAPI
    runs-on: ubuntu-latest

    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_REGION: eu-west-1
      DEV_ACCOUNT_ID: ${{ secrets.DEV_ACCOUNT_ID }}
      STAGING_ACCOUNT_ID: ${{ secrets.STAGING_ACCOUNT_ID }}
      PROD_ACCOUNT_ID: ${{ secrets.PROD_ACCOUNT_ID }}

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ env.NODEJS_VERSION }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ env.NODEJS_VERSION }}

      # Other steps here include npm install, linting, unit tests, etc

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: 'Deploying to stage: dev'
        uses: './.github/actions/sls-deploy'
        with:
          service-folder: ${{ env.SERVICE_FOLDER }}
          stage: dev
          aws-account-id: ${{ env.DEV_ACCOUNT_ID }}
      # Post-deployment test steps go here

      - name: 'Deploying to stage: staging'
        uses: './.github/actions/sls-deploy'
        with:
          service-folder: ${{ env.SERVICE_FOLDER }}
          stage: staging
          aws-account-id: ${{ env.STAGING_ACCOUNT_ID }}

      # Post-deployment test steps go here
      - name: 'Deploying to stage: prod'
        uses: './.github/actions/sls-deploy'
        with:
          service-folder: ${{ env.SERVICE_FOLDER }}
          stage: prod
          aws-account-id: ${{ env.PROD_ACCOUNT_ID }}
      # Post-deployment test steps go here
# `.github/actions/sls-deploy/action.yml` file
name: sls-deploy
description: Deploys a Serverless Framework service
inputs:
  service-folder:
    description: Folder containing serverless.yml to be deployed
    required: true
  stage:
    description: Stage to be deployed to
    required: true
  aws-account-id:
    description: AWS Account Id of target environment
    required: true
  app-id:
    description: Name of the app
    required: true
    default: slslaunchpad

runs:
  using: 'composite'
  steps:
    - name: 'Deploy: ${{ inputs.service-folder }} [${{ inputs.stage }}]'
      working-directory: ${{ inputs.service-folder }}
      shell: bash
      run: |
        CREDS=`aws sts assume-role --role-arn arn:aws:iam::${{ inputs.aws-account-id }}:role/${{ inputs.app-id }}-DeployerRole --role-session-name=gha_deployer`
        export AWS_ACCESS_KEY_ID=`echo $CREDS | jq -r '.Credentials.AccessKeyId'`
        export AWS_SECRET_ACCESS_KEY=`echo $CREDS | jq -r '.Credentials.SecretAccessKey'`
        export AWS_SESSION_TOKEN=`echo $CREDS | jq -r '.Credentials.SessionToken'`
        export STAGE=${{inputs.stage}}
        export SLS_DEBUG="*"
        npm run deploy

There are two key parts of this workflow to note:

  1. The “Configure AWS credentials” step. This is where the GHA runner authenticates with AWS using the IAM user credentials stored in GitHub secrets.
  2. The last few lines of the sls-deploy/action.yml file where the aws sts assume-role command assumes the DeployerRole role inside the target account. The subsequent call to npm run deploy is where your deployment framework’s deploy command is executed.

If you would rather not store long-lived IAM user credentials as secrets inside GitHub Actions, check out actions2aws which uses some extra infrastructure inside your AWS account to avoid the need to do this.

Running post-deployment tests under a dedicated role

After you have performed the deployment within your pipeline, you will probably wish to run some tests, be they integration, E2E or smoke tests. If these tests need to call onto the AWS API (e.g. to seed/read data in DynamoDB or publish an event to EventBridge), you can create a separate IAM role with its own least-privilege permission set. Then use the same technique as with the DeployerRole to have your build step assume this role before running the tests. This approach reduces the risk of your tests deleting or modifying something they shouldn’t.

Need help with production-proofing your IAM setup?

If all this sounds like a lot to do and you'd like some help with getting it set up for a new project, you might be interested in my Serverless Launchpad service.

I can build you a secure, multi-account AWS org, with code structure, dev environments and delivery pipelines all pre-installed, allowing your team to focus on building features for your app.

Learn more >

Further reading & useful tools

  • AWS IAM Service Authorization Reference – Official AWS docs documenting granular IAM permissions, principals and resources for every AWS service. Keep this close at hand when defining your deployment policies.
  • Effective IAM book by Stephen Kuenzli. “If you’re struggling to deliver effective AWS security policies, this guide will help you understand why it’s hard and how both you and your organization can use IAM well.”
  • cfnflip — quickly flip your CloudFormation template (or IAM policy/role) that you copied off some website from JSON to YAML (or vice versa if you really must), maintaining the !Ref, !Sub, etc, intrinsic functions.
  • actions2aws — AWS IAM roles for GitHub Actions workflows without storing long-lived credentials
  • OrgFormation — Superb IaC tool for managing AWS Organization-level landing zone configuration. Basically all the stuff that your application stacks can assume is already in place within your AWS account.
Originally published .

Other articles you might enjoy:

Free Email Course

How to transition your team to a serverless-first mindset

In this 5-day email course, you’ll learn:

  • Lesson 1: Why serverless is inevitable
  • Lesson 2: How to identify a candidate project for your first serverless application
  • Lesson 3: How to compose the building blocks that AWS provides
  • Lesson 4: Common mistakes to avoid when building your first serverless application
  • Lesson 5: How to break ground on your first serverless project

    🩺
    Architecture & Process Review

    Built a serverless app on AWS, but struggling with performance, maintainability, scalability or DevOps practices?

    I can help by reviewing your codebase, architecture and delivery processes to identify risk areas and their causes. I will then recommend solutions and help you with their implementation.

    Learn more >>

    🪲 Testing Audit

    Are bugs in production slowing you down and killing confidence in your product?

    Get a tailored plan of action for overhauling your AWS serverless app’s tests and empower your team to ship faster with confidence.

    Learn more >>