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:
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:
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:
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:
And three Header Parameters on the response:
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.
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:
- 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 totrue
ifDomainNameParameter
is not equal to the empty string. - The
BasePathMapping
resource depends on theTheDeploymentStage
resource. - One of the outputs of the
DomainName
resource is a field namedRegionalDomainName
. 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 theRegionalDomainName
.
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 theAreMappingToCustomDomain
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
: