Sit Back And Relax

Deploying An ASP.NET Core Application To AWS With CloudFormation

by JamesQMurphy | August 18, 2019

Now that we have a continuous build process set up, it's time to take those rich, aromatic artifacts and deploy them to AWS -- automatically!

This article references Release 0.0.10 and Release 0.0.11. Release 0.0.10 contained the original version, but I made a last-minute change and thus, Release 0.0.11. Since my previous post, I've made some basic changes to the ASP.NET application itself, which is why we're at Release 0.0.10 in the first place, but I'm not really going to cover those changes. I'm just going to cover the CloudFormation template. The full template is included near the end of this article, but if you prefer a direct link to GitHub, here you go.

CloudFormation

AWS CloudFormation is Amazon's implementation of Infrastructure as Code and is similar to Azure's ARM templates, Chef's Cookbooks, and Ansible's Playbooks. CloudFormation is totally free to use, although you still have to pay for the resources that get created with CloudFormation.

With CloudFormation, you list the resources that you need in a template file, in either JSON or YAML format. Initially, I used JSON to define my template, but I switched to YAML for two reasons:

  • You can insert comments inside YAML documents. You can't do that with JSON-formatted CloudFormation templates; comments need to be added as metadata nodes, which is a pain.
  • In some sections of the CloudFormation template, it made more sense to include an entire definition on one line. I got sick of Visual Studio's editor auto-formatting my one-liners, even when I wasn't even editing that particular section.

When invoked, AWS CloudFormation reads your template and attempts to create the resoures. Assuming the operation is successful, the resulting set of resources is called a stack. If you make changes to a template (e.g., add a new resource), you can simply re-apply the template to an already-running stack and AWS will make whatever changes are necessary (e.g., creating the new resource). Deleting the stack will also delete all the resources in that stack (although you can override this for certain resources). This is a huge boon if your AWS environment is littered with unused resources from past applications. But it does carry some implications, as we'll see later.

Adding the CloudFormation Template as a Build Artifact

In a previous post, I touched upon the difference between the build system and the deployment system. Recall that the primary job of the build system (in our case, the build pipeline) was to produce artifacts. We set that up in the last post, but we do have a change to make: The CloudFormation template is a new artifact that needs to be included. I added a build step to copy the CloudFormation template from its source folder (deploy) to the artifact staging directory:

- task: CopyFiles@2
  displayName: Copy $(JQM.cloudformationFileName) to artifact staging folder
  inputs:
    SourceFolder: 'deploy'
    Contents: '$(JQM.cloudformationFileName)'
    TargetFolder: '$(Build.ArtifactStagingDirectory)'

I also changed the publish step to publish the entire folder, not just the .zip file. I also renamed the artifact name as "deploy" since it represents the artifacts that need to be deployed:

- task: PublishBuildArtifacts@1
  displayName: Publish build artifacts
  inputs:
    pathtoPublish: '$(Build.ArtifactStagingDirectory)' 
    artifactName: 'deploy' 

And as you can see from the resulting build, the single artifact "deploy" contains both the .zip file and the CloudFormation template:

Build artifact from build

Viewing the contents of the deploy artifact with the Artifacts explorer

Creating the Release Pipeline

Now that we have the necessary artifacts, we can create the release pipeline itself. I won't focus on all of the details of the pipeline (you can view the details of the actual release here), but I'll describe the overall structure. I divided my release pipeline into two phases:

  • Phase 1: Uploading the build artifacts into AWS
  • Phase 2: Applying the CloudFormation template

In the first phase, I upload the build artifacts into a private S3 bucket (named www-jamesqmurphy-com-storage). I also unzip the contents of the wwwroot folder and upload them into the same bucket; recall that we will be setting up a /dist/{proxy+} resource for the static files. After the upload phase is complete, this is what the corresponding folder in the S3 bucket looks like:

Contents of the build_artifacts folder in the S3 bucket

The second phase consists of applying the CloudFormation template, which will actually do the work of creating each of the resources defined. Let's examine the key parts of the template.

The Structure of the CloudFormation Template

The AWS documentation has a good explanation of the structure of a CloudFormation template, so I'll only describe the relevant sections in my template.

Parameters

The first section of the CloudFormation template describes the parameters. I've listed them here along with the values for a deployment to dev; I'll describe how each is used when we get to the applicable resource. Note that each parameter name has a suffix of "Parameter"; this is just a convention that I came up with. Use whatever naming convention suits your needs, but I do recommend using some sort of convention to help distinguish parameters from other items.

Parameter Values for the "dev" stage of this release
VersionNumberParameter 0.0.10-master.1
S3BucketForCodeParameter www-jamesqmurphy-com-storage
S3BucketPathForCodeParameter build_artifacts\0.0.10-master.1
S3BucketForStaticFilesParameter build_artifacts\0.0.10-master.1
StageParameter dev
DomainNameParameter dev.jamesqmurphy.com
CertificateArnParameter (redacted)
AppNameParameter JamesQMurphyWeb
ApiGatewayStageNameParameter website

Resources

The Resources section lists each item that you want CloudFormation to create or modify. I'll describe each resource in detail. You'll notice that I've adopted a naming convention of prefixing each resource with "The"; it's what made the template most understandable to me. Again, use your own convention, but I do suggest you use one.

TheRoleForTheLambdaFunction

What better way to start off the discussion than with a little bit of drama. When I first created the proof-of-concept Lambda function, I let AWS automatically create the execution role for me. But in CloudFormation, you must explicitly define the execution role. It doesn't have to be part of the template, and sometimes you don't want it to be (more on that in a minute), but it needs to be explicitly defined. For now, I've chosen to include the role in the template, and I've also chosen to use inline policies, which can also be a contentious choice. TheRoleForTheLambdaFunction has the bare minimum level of permissions needed for a Lambda execution role; all it really needs is write access to CloudWatch logs. Here is a snippet of the inline policy:

- Effect: Allow
  Action:
    - 'logs:CreateLogGroup'
    - 'logs:CreateLogStream'
    - 'logs:PutLogEvents'
  Resource: '*'

So what's the controversy? The concern is that whatever service account you use that creates or updates the CloudFormation stack needs to have sufficient permissions to create, update, and even delete roles. This means that I could theoretically define a role that has carte blanche access to all AWS resources, and the service account could create it. There are, of course, numerous ways to mitigate this kind of risk. Such a discussion warrants an entire separate article, and I'll address it in the future. One thing that I did do was to limit my service account so that it could only create roles, and not other IAM entities like users, groups, and named policies.

TheRoleForTheApiGateway

Here, too, we've got some drama to deal with. I'll start with the name; even though it is called TheRoleForTheApiGateway, it is really the role for the methods underneath the /dist/{proxy+} resource. Recall that we created that resource to read static files directly from an S3 Bucket, instead of having the Lambda function return the static files. Thus, in addition to writing to CloudWatch, the role requires read permissions to S3. I took it a step further and granted it read-only access to the specific folder inside of S3. You can see how the S3BucketForCodeParameter and S3BucketPathForStaticFilesParameter parameters come into play:

- Effect: Allow
  Action:
    - 's3:Get*'
    - 's3:List*'
  Resource: !Sub 'arn:aws:s3:::${S3BucketForCodeParameter}/${S3BucketPathForStaticFilesParameter}/*'

TheLambdaFunction

Thankfully, the defintion for the Lambda Function is comparatively straightforward. Most of the properties are the same properties that we specified when we manually set up the proof-of-concept Lambda earlier. The main difference is that instead of uploading the .zip file directly to Lambda, we specify the location of the .zip file inside an S3 bucket (which get passed in as the two parameters S3BucketForCodeParameter and S3BucketPathForCodeParameter). You can also see how we supply the ARN to the execution role using the CloudFormation instrinsic function Fn::GetAtt. Note that in YAML, you can use a short-name syntax for intrinsic functions, so instead of writing Fn::GetAtt, you can write !GetAtt:

TheLambdaFunction:
  Type: 'AWS::Lambda::Function'
  Properties:
    Code:
      S3Bucket: !Ref S3BucketForCodeParameter
      S3Key: !Ref S3BucketPathForCodeParameter
    FunctionName: (see below)
    Handler: JamesQMurphy.Web::JamesQMurphy.Web.LambdaEntryPoint::FunctionHandlerAsync
    MemorySize: 512
    Role: !GetAtt TheRoleForTheLambdaFunction.Arn
    Runtime: provided  # custom runtime
    Timeout: 15

The trickiest part was the name of the Lambda function. I wanted the Lambda function name to include some combination of the version number and the stage, so it would be something like 0.0.10-master.1-dev. However, Lambda functions cannot have periods in their name, so I decided to replace the periods with hyphens. Unfortunately, CloudFormation does not have a "Replace" function, so I had to get a little clever with the functions that were available; i.e., Fn::Join, Fn::Select, Fn::Split, and Fn::Sub (with short names !Join, !Select, !Split, and !Sub):

FunctionName: !Join
  - '-'
  - - !Ref AppNameParameter
    # These !Select functions parse out the pieces of the version number
    - !Select [0, !Split [".", !Ref VersionNumberParameter]]
    - !Select [1, !Split [".", !Sub '${VersionNumberParameter}.' ]]
    - !Select [2, !Split [".", !Sub '${VersionNumberParameter}..' ]]
    - !Select [3, !Split [".", !Sub '${VersionNumberParameter}...' ]]
    - !Select [4, !Split [".", !Sub '${VersionNumberParameter}....' ]]
    - !Ref StageParameter

This can result in extra hyphens in the resulting name (e.g., JamesQMurphyWeb-0-0-10-master-1--dev), but at least I can clearly identify which version and stage the Lambda represents.

TheGatewayRestAPI

Declaring the API Gateway resource is even easier, since you can have periods in the name. It's also a piece of cake to add the Binary Media types I discussed earlier:

TheGatewayRestAPI:
  Type: 'AWS::ApiGateway::RestApi'
  Properties:
    Name: !Sub ${AppNameParameter}-${VersionNumberParameter}-${StageParameter}
    BinaryMediaTypes:
      - '*/*'
    EndpointConfiguration:
      Types:
        - REGIONAL

It's the individual resources and methods inside the API where things get tricky. Recall that when I set up the API Gateway for the Proof-Of-Concept Lambda function, I had to create two methods -- one on the root of the API itself, and one under the proxy resource, to handle everything else. I still need to do that here. So let's start with the root of the API:

TheRootGetMethod

I don't need to declare the root resource of the API Gateway -- it gets created as part of the API Gateway, and is available using the Fn::GetAtt function on the TheGatewayRestAPI resource. But I do need to define the methods. When I did it manually, I set up the method as an ANY method out of convenience; it was the default method offered, and replacing it with a GET method actually takes more work. That's not the case here -- it's simply a property and I just specify which method I want. I've chosen the GET method since I don't anticipate allowing any other actions against the root:

TheRootGetMethod:
  Type: 'AWS::ApiGateway::Method'
  Properties:
    RestApiId: !Ref TheGatewayRestAPI
    ResourceId: !GetAtt TheGatewayRestAPI.RootResourceId
    HttpMethod: GET
    AuthorizationType: NONE
    Integration:
      Type: AWS_PROXY
      IntegrationHttpMethod: POST
      Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TheLambdaFunction.Arn}/invocations

Note the value of the Uri property. That is the magic needed to map an API Gateway method to a Lambda function using the AWS_PROXY integration.

TheProxyResource

Before I can define the methods on the proxy resource, I need to define the proxy resource itself, like so:

TheProxyResource:
  Type: 'AWS::ApiGateway::Resource'
  Properties:
    RestApiId: !Ref TheGatewayRestAPI
    ParentId: !GetAtt TheGatewayRestAPI.RootResourceId
    PathPart: '{proxy+}'

TheProxyAnyMethod

Now we can set up the method, and since we could potentially allow other HTTP verbs like POST, I'll define this one as an ANY method. Notice how this definition looks essentially the same as TheRootGetMethod:

TheProxyAnyMethod:
  Type: 'AWS::ApiGateway::Method'
  Properties:
    RestApiId: !Ref TheGatewayRestAPI
    ResourceId: !Ref TheProxyResource
    HttpMethod: ANY
    AuthorizationType: NONE
    Integration:
      Type: AWS_PROXY
      IntegrationHttpMethod: POST
      Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TheLambdaFunction.Arn}/invocations

TheLambdaInvokePermissionForProxyResource

Remember when AWS asked us to grant invoke permission from the API Gateway to the Lambda function? We got a popup that looked like this:

Asking for invoke permission from API Gateway to Lambda

We still need to specify that permission, and it takes the form of a permission resource. Don't forget the asterisk (*) at the end of the SourceArn property:

TheLambdaInvokePermissionForProxyResource:
  Type: 'AWS::Lambda::Permission'
  Properties:
    FunctionName: !GetAtt TheLambdaFunction.Arn
    Action: 'lambda:InvokeFunction'
    Principal: apigateway.amazonaws.com
	# Don't forget the "*" at the end of the SourceArn:
    SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${TheGatewayRestAPI}*

That takes care of the integration with the Lambda function. Now we can create the dist resource that fetches static content from the S3 bucket.

TheDistResource and TheProxyResourceUnderTheDistResource

First, we need to define the dist resource and the proxy resource underneath:

TheDistResource:
  Type: 'AWS::ApiGateway::Resource'
  Properties:
    RestApiId: !Ref TheGatewayRestAPI
    ParentId: !GetAtt TheGatewayRestAPI.RootResourceId
    PathPart: dist

TheProxyResourceUnderTheDistResource:
  Type: 'AWS::ApiGateway::Resource'
  Properties:
    RestApiId: !Ref TheGatewayRestAPI
    ParentId: !Ref TheDistResource
    PathPart: '{proxy+}'

The real challenge lies in defining the GET method for the /dist/{proxy+} resource.

TheGetMethodForTheProxyResourceUnderTheDistResource

Recall that when I set up the GET method for the /dist/{proxy+} release, I set up a URL Path Parameter in the request:

URL Path Parameter

And three Header Parameters on the response:

Response Header Parameters

To define these in CloudFormation, first add them as properties:

RequestParameters:
  method.request.path.proxy: true
MethodResponses:
  - StatusCode: 200
    ResponseParameters:
      'method.response.header.Timestamp': true
      'method.response.header.Content-Length': true
      'method.response.header.Content-Type': true

Then, in the Integration section, enter the mappings:

Integration:
  Type: AWS
  IntegrationHttpMethod: GET
  Credentials: !GetAtt TheRoleForTheApiGateway.Arn
  Uri: !Sub arn:aws:apigateway:${AWS::Region}:s3:path/${S3BucketForCodeParameter}/${S3BucketPathForStaticFilesParameter}/dist/{fullpath}
  PassthroughBehavior: WHEN_NO_MATCH
  RequestParameters:
    integration.request.path.fullpath: 'method.request.path.proxy'
  IntegrationResponses:
  - StatusCode: 200
    ResponseParameters:
      'method.response.header.Timestamp': 'integration.response.header.Date'
      'method.response.header.Content-Length': 'integration.response.header.Content-Length'
      'method.response.header.Content-Type': 'integration.response.header.Content-Type'

TheDeploymentStage

This one caused me a great deal of confusion. First, the Deployment resource needs to have a dependency on all the other resources in the API; otherwise the API will be deployed before those other resources get created. This peculiarity is documented, but it's easy to forget:

TheDeploymentStage:
  Type: 'AWS::ApiGateway::Deployment'
  DependsOn:
    - TheRootGetMethod
    - TheProxyAnyMethod
    - TheDistResource
    - TheProxyResourceUnderTheDistResource
    - TheGetMethodForTheProxyResourceUnderTheDistResource
  Properties:
    Description: !Sub ${AppNameParameter}-${VersionNumberParameter} ${StageParameter}.  Created via CloudFormation stack ${AWS::StackName}.
    RestApiId: !Ref TheGatewayRestAPI
    StageName: !Ref ApiGatewayStageNameParameter

But there's more. The official type of this resource is AWS::ApiGateway::Deployment, but in reality what you are setting up is the deployment stage. The first time the template is applied, it will create the Deployment resource, which deploys the API. But (and this is the important part), any subsequent updates to the stack will not cause the API to be redeployed. This is because of the way CloudFormation works; it will only create brand-new resources, or modify resources that have changed. So unless you change the Deployment resource somehow, the API Gateway will only be deployed once by CloudFormation.

A terrible Johnny Dangerously meme saying that CloudFormation deploys API Gateway Once... Once!

There are workarounds to this issue, but the simplest is just to add a step to the release process to deploy the API. So that's exactly what I did: I added a step to the release pipeline that deploys the API Gateway, using AWS Tools for PowerShell:

Write-Output "Stack ID is $($env:JQM_STACKID)"
$api = Get-CFNStackResource -StackName $env:JQM_STACKID -LogicalResourceId 'TheGatewayRestAPI'
Write-Output "Gateway API Resource ID is $($api.PhysicalResourceId)"
New-AGDeployment -RestApiId $api.PhysicalResourceId -StageName '$(JQM.ApiGatewayStageName)' -Description 'Deployed via Release $(Release.ReleaseName)'

I may come up with a more elegant solution down the road, but for now, this additional step will do.

TheCustomDomainName and TheBasePathMapping

I didn't describe how to map an API Gateway to a custom URL; there are plenty of resources on the web that can explain how to do it manually. In CloudFormation, it involves two resources: a DomainName resource and a BasePathMapping resource. There are a few key points that I want to highlight:

  1. I wanted the ability to skip the step. Down the road, I want the ability to deploy a build and have it be fully functional directly via the API Gateway link. I accomplished this using a CloudFormation Condition named AreMappingToCustomDomain, which evaluates to true if DomainNameParameter is not equal to the empty string.
  2. The BasePathMapping resource depends on the TheDeploymentStage resource.
  3. One of the outputs of the DomainName resource is a field named RegionalDomainName. I need this value for my DNS entry; specifically, I need to have a DNS CNAME record that points my domain name (www.jamesqmurphy.com, staging.jamesqmurphy.com, etc.) to the RegionalDomainName.
TheCustomDomainName:
  Type: 'AWS::ApiGateway::DomainName'
  Condition: AreMappingToCustomDomain
  Properties:
    DomainName: !Ref DomainNameParameter
    RegionalCertificateArn: !Ref CertificateArnParameter
    EndpointConfiguration:
      Types:
        - REGIONAL

TheBasePathMapping:
  Type: 'AWS::ApiGateway::BasePathMapping'
  Condition: AreMappingToCustomDomain
  DependsOn:
    - TheDeploymentStage
  Properties:
    DomainName: !Ref TheCustomDomainName
    RestApiId: !Ref TheGatewayRestAPI
    Stage: !Ref ApiGatewayStageNameParameter

Outputs

Once CloudFormation creates a stack, it can capture certain values as output values. Below is a quick summary of the output values that I capture; I'm hoping to include these values in the Azure DevOps release summary down the road:

  • GatewayApiUrl: This is the URL that API Gateway creates for your API and stage.
  • SiteUrl: This is simply the DomainNameParameter parameter echoed back, with an "https://" tacked on the front. It uses the AreMappingToCustomDomain condition, so it will only be present if we pass in a domain name as a parameter.
  • TargetDomainName: This is the Domain Name for which I need to create a CNAME record. Unfortunately, the DNS Provider that I use (Hover) does not support any automation, so I can't automatically create or update the CNAME record. I can do a check and notify me if the DNS record needs to be changed.

The Complete CloudFormation Template

As promised, here is the complete CloudFormation template.

AWSTemplateFormatVersion: 2010-09-09

Parameters:
 VersionNumberParameter:
   Type: String
   Description: Version number of build

 S3BucketForCodeParameter:
   Type: String
   Description: S3 Bucket where code is located

 S3BucketPathForCodeParameter:
   Type: String
   Description: Path to code inside of bucket.  Include the zip filename, but do not include a leading backslash.

 S3BucketPathForStaticFilesParameter:
   Type: String
   Description: Path to static files inside of bucket.  Do not include a leading backslash.

 StageParameter:
   Type: String
   Description: Stage (environment)
   Default: dev

 DomainNameParameter:
   Type: String
   Description: Custom Domain Name (e.g., www.jamesqmurphy.com).  Leave blank if not mapping to a custom URL.
   Default: ''

 CertificateArnParameter:
   Type: String
   Description: Certificate ARN.  Leave blank if not mapping to a custom URL.
   Default: ''

 AppNameParameter:
   Type: String
   Description: 'Name of app to use in descriptions, etc.'
   Default: JamesQMurphyWeb

 ApiGatewayStageNameParameter:
   Type: String
   Description: Name to use for the API Gateway Stage
   Default: website


Conditions:
  AreMappingToCustomDomain: !Not [!Equals [!Ref DomainNameParameter, '']]


Resources:

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


 TheRoleForTheApiGateway:
   Type: 'AWS::IAM::Role'
   Properties:
     AssumeRolePolicyDocument:
       Version: 2012-10-17
       Statement:
         - Effect: Allow
           Principal:
             Service:
               - apigateway.amazonaws.com
           Action:
             - 'sts:AssumeRole'
     Policies:
       - PolicyName: CloudwatchWriteOnlyAccess
         PolicyDocument:
           Version: 2012-10-17
           Statement:
             - Effect: Allow
               Action:
                 - 'logs:CreateLogGroup'
                 - 'logs:CreateLogStream'
                 - 'logs:PutLogEvents'
               Resource: '*'
       - PolicyName: !Sub S3ReadOnly-${S3BucketForCodeParameter}
         PolicyDocument:
           Version: 2012-10-17
           Statement:
             - Effect: Allow
               Action:
                 - 's3:Get*'
                 - 's3:List*'
               Resource: !Sub 'arn:aws:s3:::${S3BucketForCodeParameter}/${S3BucketPathForStaticFilesParameter}/*'


 TheLambdaFunction:
   Type: 'AWS::Lambda::Function'
   Properties:
     Code:
       S3Bucket: !Ref S3BucketForCodeParameter
       S3Key: !Ref S3BucketPathForCodeParameter
     Description: !Sub Hosts ${AppNameParameter}-${VersionNumberParameter} ${StageParameter} ASP.NET Core application.  Created via CloudFormation stack ${AWS::StackName}.
     # Lambda does not allow periods in function names, so to include the version number,
     # we need to replace the periods with something else (hyphens in this case)
     FunctionName: !Join
       - '-'
       - - !Ref AppNameParameter
           # These !Select functions parse out the pieces of the version number
         - !Select [0, !Split [".", !Ref VersionNumberParameter]]
         - !Select [1, !Split [".", !Sub '${VersionNumberParameter}.' ]]
         - !Select [2, !Split [".", !Sub '${VersionNumberParameter}..' ]]
         - !Select [3, !Split [".", !Sub '${VersionNumberParameter}...' ]]
         - !Select [4, !Split [".", !Sub '${VersionNumberParameter}....' ]]
         - !Ref StageParameter
     Handler: JamesQMurphy.Web::JamesQMurphy.Web.LambdaEntryPoint::FunctionHandlerAsync
     MemorySize: 512
     Role: !GetAtt TheRoleForTheLambdaFunction.Arn
     Runtime: provided  # custom runtime
     Timeout: 15
     Tags:
       - Key: app
         Value: !Ref AppNameParameter
       - Key: version
         Value: !Ref VersionNumberParameter


 TheGatewayRestAPI:
   Type: 'AWS::ApiGateway::RestApi'
   Properties:
     Name: !Sub ${AppNameParameter}-${VersionNumberParameter}-${StageParameter}
     Description: !Sub ${AppNameParameter}-${VersionNumberParameter} ${StageParameter}. Created via CloudFormation stack ${AWS::StackName}.
     BinaryMediaTypes:
       - '*/*'
     EndpointConfiguration:
       Types:
         - REGIONAL


 TheRootGetMethod:
   Type: 'AWS::ApiGateway::Method'
   Properties:
     AuthorizationType: NONE
     HttpMethod: GET
     Integration:
       Type: AWS_PROXY
       IntegrationHttpMethod: POST
       Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TheLambdaFunction.Arn}/invocations
     ResourceId: !GetAtt TheGatewayRestAPI.RootResourceId
     RestApiId: !Ref TheGatewayRestAPI


 TheProxyResource:
   Type: 'AWS::ApiGateway::Resource'
   Properties:
     RestApiId: !Ref TheGatewayRestAPI
     ParentId: !GetAtt TheGatewayRestAPI.RootResourceId
     PathPart: '{proxy+}'


 TheProxyAnyMethod:
   Type: 'AWS::ApiGateway::Method'
   Properties:
     RestApiId: !Ref TheGatewayRestAPI
     ResourceId: !Ref TheProxyResource
     HttpMethod: ANY
     AuthorizationType: NONE
     Integration:
       Type: AWS_PROXY
       IntegrationHttpMethod: POST
       Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TheLambdaFunction.Arn}/invocations


 TheDistResource:
   Type: 'AWS::ApiGateway::Resource'
   Properties:
     RestApiId: !Ref TheGatewayRestAPI
     ParentId: !GetAtt TheGatewayRestAPI.RootResourceId
     PathPart: dist


 TheProxyResourceUnderTheDistResource:
   Type: 'AWS::ApiGateway::Resource'
   Properties:
     RestApiId: !Ref TheGatewayRestAPI
     ParentId: !Ref TheDistResource
     PathPart: '{proxy+}'


 TheGetMethodForTheProxyResourceUnderTheDistResource:
   Type: 'AWS::ApiGateway::Method'
   Properties:
     RestApiId: !Ref TheGatewayRestAPI
     ResourceId: !Ref TheProxyResourceUnderTheDistResource
     HttpMethod: GET
     AuthorizationType: NONE
     RequestParameters:
       method.request.path.proxy: true
     MethodResponses:
       - StatusCode: 200
         ResponseParameters:
           'method.response.header.Timestamp': true
           'method.response.header.Content-Length': true
           'method.response.header.Content-Type': true
     Integration:
       Type: AWS
       IntegrationHttpMethod: GET
       Credentials: !GetAtt TheRoleForTheApiGateway.Arn
       Uri: !Sub arn:aws:apigateway:${AWS::Region}:s3:path/${S3BucketForCodeParameter}/${S3BucketPathForStaticFilesParameter}/dist/{fullpath}
       PassthroughBehavior: WHEN_NO_MATCH
       RequestParameters:
         integration.request.path.fullpath: 'method.request.path.proxy'
       IntegrationResponses:
       - StatusCode: 200
         ResponseParameters:
           'method.response.header.Timestamp': 'integration.response.header.Date'
           'method.response.header.Content-Length': 'integration.response.header.Content-Length'
           'method.response.header.Content-Type': 'integration.response.header.Content-Type'


 TheDeploymentStage:
   Type: 'AWS::ApiGateway::Deployment'
   DependsOn:
     - TheRootGetMethod
     - TheProxyAnyMethod
     - TheDistResource
     - TheProxyResourceUnderTheDistResource
     - TheGetMethodForTheProxyResourceUnderTheDistResource
   Properties:
     Description: !Sub ${AppNameParameter}-${VersionNumberParameter} ${StageParameter}.  Created via CloudFormation stack ${AWS::StackName}.
     RestApiId: !Ref TheGatewayRestAPI
     StageName: !Ref ApiGatewayStageNameParameter


 TheLambdaInvokePermissionForProxyResource:
   Type: 'AWS::Lambda::Permission'
   Properties:
     FunctionName: !GetAtt TheLambdaFunction.Arn
     Action: 'lambda:InvokeFunction'
     Principal: apigateway.amazonaws.com
     SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${TheGatewayRestAPI}*


 TheCustomDomainName:
   Type: 'AWS::ApiGateway::DomainName'
   Condition: AreMappingToCustomDomain
   Properties:
     DomainName: !Ref DomainNameParameter
     RegionalCertificateArn: !Ref CertificateArnParameter
     EndpointConfiguration:
       Types:
         - REGIONAL


 TheBasePathMapping:
   Type: 'AWS::ApiGateway::BasePathMapping'
   Condition: AreMappingToCustomDomain
   DependsOn:
     - TheDeploymentStage
   Properties:
     DomainName: !Ref TheCustomDomainName
     RestApiId: !Ref TheGatewayRestAPI
     Stage: !Ref ApiGatewayStageNameParameter


Outputs:
 GatewayApiUrl:
   Description: The Url to the Gateway API
   Value: !Sub https://${TheGatewayRestAPI}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${ApiGatewayStageNameParameter}

 SiteUrl:
   Description: The Url to the site
   Value: !Sub https://${DomainNameParameter}
   Condition: AreMappingToCustomDomain

 TargetDomainName:
   Description: Target domain name for DNS mapping
   Value: !GetAtt TheCustomDomainName.RegionalDomainName
   Condition: AreMappingToCustomDomain

One Template, Three Stacks

The beauty of this setup is that I have one template that describes the AWS resources I need for an environment. For the release pipeline, I created three environments (Dev, Staging, and Production), with a stack in each environment. Here, I've highlighted the Outputs tab of the stack named dev-jamesqmurphy-com:

CloudFormation stacks in the AWS Console