-
-
Save filipeandre/465335b376f9a83a9a2a3e4854acda22 to your computer and use it in GitHub Desktop.
A SAM template that describe an Amazon CloudFront distribution that serve a static website from an S3 Bucket.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
AWSTemplateFormatVersion: 2010-09-09 | |
Description: "Personal Website" | |
Parameters: | |
DomainName: | |
Type: String | |
Description: "The domain name of website" | |
BucketName: | |
Type: String | |
Description: "The bucket name" | |
HostedZoneId: | |
Type: String | |
Description: "The Route53 hosted zone ID used for the domain" | |
AcmCertificateArn: | |
Type: String | |
Description: "The certificate arn for the domain name provided" | |
IndexDocument: | |
Type: String | |
Description: "The index document" | |
Default: "index.html" | |
ErrorDocument: | |
Type: String | |
Description: "The error document, ignored in SPA mode" | |
Default: "404.html" | |
RewriteMode: | |
Type: String | |
Description: "The request rewrite behaviour type" | |
Default: "STATIC" | |
AllowedValues: | |
- STATIC | |
- SPA | |
CloudFrontPriceClass: | |
Type: String | |
Description: "The price class for CloudFront distribution" | |
Default: "PriceClass_100" | |
AllowedValues: | |
- PriceClass_100 | |
- PriceClass_200 | |
- PriceClass_All | |
CreateBucket: | |
Type: String | |
Description: "Flag to determine if the bucket should be created by this stack" | |
Default: "true" | |
AllowedValues: | |
- "true" | |
- "false" | |
Conditions: | |
IsStaticMode: !Equals [!Ref RewriteMode, "STATIC"] | |
IsSPAMode: !Equals [!Ref RewriteMode, "SPA"] | |
ShouldCreateBucket: !Equals [!Ref CreateBucket, "true"] | |
NotShouldCreateBucket: !Equals [!Ref CreateBucket, "false"] | |
Resources: | |
OriginAccessIdentity: | |
Type: AWS::CloudFront::CloudFrontOriginAccessIdentity | |
Properties: | |
CloudFrontOriginAccessIdentityConfig: | |
Comment: !Ref AWS::StackName | |
Bucket: | |
Condition: ShouldCreateBucket | |
Type: AWS::S3::Bucket | |
Properties: | |
BucketName: !Ref BucketName | |
WebsiteConfiguration: | |
IndexDocument: !Ref IndexDocument | |
ErrorDocument: !Ref ErrorDocument | |
AccessControl: Private | |
BucketPolicy: | |
Condition: ShouldCreateBucket | |
Type: AWS::S3::BucketPolicy | |
Properties: | |
Bucket: !Ref DomainName | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Principal: | |
CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId | |
Action: "s3:GetObject" | |
Resource: !Sub "${Bucket.Arn}/*" | |
BucketPolicyCustomResource: | |
Condition: NotShouldCreateBucket | |
Type: Custom::UpdateBucketPolicy | |
Properties: | |
ServiceToken: !GetAtt UpdateBucketPolicyFunction.Arn | |
BucketName: !Ref BucketName | |
OriginAccessIdentityId: !GetAtt OriginAccessIdentity.S3CanonicalUserId | |
UpdateBucketPolicyFunction: | |
Condition: NotShouldCreateBucket | |
Type: AWS::Lambda::Function | |
Properties: | |
Runtime: python3.11 | |
Handler: index.handler | |
Code: | |
ZipFile: | | |
import json | |
import boto3 | |
import cfnresponse | |
s3 = boto3.client('s3') | |
def handler(event, context): | |
request_type = event['RequestType'] # Get the request type (Create, Update, Delete) | |
bucket_name = event['ResourceProperties']['BucketName'] | |
origin_access_identity_id = event['ResourceProperties']['OriginAccessIdentityId'] | |
# Handle creation and update | |
if request_type in ['Create', 'Update']: | |
policy = { | |
"Version": "2012-10-17", | |
"Statement": [ | |
{ | |
"Effect": "Allow", | |
"Principal": { | |
"CanonicalUser": origin_access_identity_id | |
}, | |
"Action": "s3:GetObject", | |
"Resource": f"arn:aws:s3:::{bucket_name}/*" | |
} | |
] | |
} | |
try: | |
# Apply the bucket policy | |
s3.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy)) | |
# Return success response to CloudFormation | |
cfnresponse.send(event, context, cfnresponse.SUCCESS, | |
{'PhysicalResourceId': bucket_name}, | |
"Bucket policy updated successfully") | |
except Exception as e: | |
# Return failure response to CloudFormation | |
cfnresponse.send(event, context, cfnresponse.FAILED, | |
{'Reason': str(e)}, | |
"Failed to update bucket policy") | |
# Handle delete request | |
elif request_type == 'Delete': | |
# Return success response to CloudFormation on delete | |
cfnresponse.send(event, context, cfnresponse.SUCCESS, | |
{'PhysicalResourceId': bucket_name}, | |
"Bucket policy deleted successfully") | |
Role: !GetAtt UpdateBucketPolicyRole.Arn | |
UpdateBucketPolicyRole: | |
Condition: NotShouldCreateBucket | |
Type: AWS::IAM::Role | |
Properties: | |
AssumeRolePolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Principal: | |
Service: lambda.amazonaws.com | |
Action: sts:AssumeRole | |
Policies: | |
- PolicyName: UpdateBucketPolicyPermissions | |
PolicyDocument: | |
Version: "2012-10-17" | |
Statement: | |
- Effect: Allow | |
Action: | |
- s3:PutBucketPolicy | |
Resource: !Sub "arn:aws:s3:::${BucketName}" | |
DnsRecord: | |
Type: AWS::Route53::RecordSet | |
Properties: | |
HostedZoneId: !Ref HostedZoneId | |
Name: !Ref DomainName | |
Type: A | |
AliasTarget: | |
DNSName: !GetAtt Distribution.DomainName | |
HostedZoneId: "Z2FDTNDATAQYW2" # CloudFront | |
WWWDnsRecord: | |
Type: AWS::Route53::RecordSet | |
Properties: | |
HostedZoneId: !Ref HostedZoneId | |
Name: !Sub "www.${DomainName}" | |
Type: A | |
AliasTarget: | |
DNSName: !GetAtt Distribution.DomainName | |
HostedZoneId: "Z2FDTNDATAQYW2" # CloudFront | |
RewriteRequestStaticFunction: | |
Condition: IsStaticMode | |
Type: AWS::CloudFront::Function | |
Properties: | |
Name: !Sub "${AWS::StackName}-req-static2" | |
AutoPublish: true | |
FunctionCode: !Sub | | |
function handler(event) { | |
const request = event.request; | |
const headers = request.headers; | |
const uri = request.uri; | |
// Redirect non-www to www | |
if (headers.host && !headers.host.value.startsWith('www.')) { | |
const host = 'www.' + headers.host.value; // Add 'www.' | |
const location = 'https://' + host + uri; | |
return { | |
statusCode: 301, | |
statusDescription: 'Moved Permanently', | |
headers: { | |
'location': { 'value': location }, | |
'strict-transport-security': { 'value': 'max-age=31536000; includeSubDomains; preload' }, | |
'x-content-type-options': { 'value': 'nosniff' }, | |
'x-frame-options': { 'value': 'DENY' }, | |
'x-xss-protection': { 'value': '1; mode=block' } | |
} | |
}; | |
} | |
// Redirect to /index.html for directories | |
if (uri.endsWith('/') || !uri.includes('.')) { | |
request.uri = uri.replace(/\/?$/, '/index.html'); | |
} | |
return request; | |
} | |
FunctionConfig: | |
Comment: !Sub "rewrite all paths to /${IndexDocument}" | |
Runtime: cloudfront-js-2.0 | |
RewriteRequestSpaFunction: | |
Condition: IsSPAMode | |
Type: AWS::CloudFront::Function | |
Properties: | |
Name: !Sub "${AWS::StackName}-req-spa2" | |
AutoPublish: true | |
FunctionCode: !Sub | | |
function handler(event) { | |
const request = event.request; | |
const headers = request.headers; | |
const uri = request.uri; | |
// Redirect non-www to www | |
if (headers.host && !headers.host.value.startsWith('www.')) { | |
const host = 'www.' + headers.host.value; // Add 'www.' | |
const location = 'https://' + host + uri; | |
return { | |
statusCode: 301, | |
statusDescription: 'Moved Permanently', | |
headers: { | |
'location': { 'value': location }, | |
'strict-transport-security': { 'value': 'max-age=31536000; includeSubDomains; preload' }, | |
'x-content-type-options': { 'value': 'nosniff' }, | |
'x-frame-options': { 'value': 'DENY' }, | |
'x-xss-protection': { 'value': '1; mode=block' } | |
} | |
}; | |
} | |
if (uri.includes('.') && !uri.endsWith('.html')) { | |
return request; | |
} | |
request.uri = '/${IndexDocument}'; | |
return request; | |
} | |
FunctionConfig: | |
Comment: !Sub "rewrite sub-directory path with trailing /${IndexDocument}" | |
Runtime: cloudfront-js-2.0 | |
Distribution: | |
Type: AWS::CloudFront::Distribution | |
Properties: | |
DistributionConfig: | |
Enabled: 'true' | |
Comment: !Ref AWS::StackName | |
DefaultRootObject: !Ref IndexDocument | |
HttpVersion: http2 | |
CustomErrorResponses: | |
- ErrorCachingMinTTL: 86400 | |
ErrorCode: 403 # object not found in bucket | |
ResponseCode: !If [IsStaticMode, 404, 200] | |
ResponsePagePath: !If [IsStaticMode, !Sub "/${ErrorDocument}", !Sub "/${IndexDocument}"] | |
Origins: | |
- DomainName: !Sub "${BucketName}.s3.${AWS::Region}.amazonaws.com" | |
Id: bucketOrigin | |
S3OriginConfig: | |
OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity} | |
DefaultCacheBehavior: | |
Compress: 'true' | |
AllowedMethods: | |
- GET | |
- HEAD | |
- OPTIONS | |
TargetOriginId: bucketOrigin | |
ForwardedValues: | |
QueryString: 'false' | |
Cookies: | |
Forward: none | |
ViewerProtocolPolicy: redirect-to-https | |
FunctionAssociations: | |
- EventType: viewer-request | |
FunctionARN: !If [IsStaticMode, !GetAtt RewriteRequestStaticFunction.FunctionMetadata.FunctionARN, !GetAtt RewriteRequestSpaFunction.FunctionMetadata.FunctionARN] | |
PriceClass: !Ref CloudFrontPriceClass | |
Aliases: | |
- !Ref DomainName | |
- !Sub "www.${DomainName}" | |
ViewerCertificate: | |
AcmCertificateArn: !Ref AcmCertificateArn | |
SslSupportMethod: sni-only | |
Outputs: | |
WebsiteURL: | |
Description: "The URL of the website" | |
Value: !Sub "https://www.${DomainName}" | |
CloudFrontDistributionID: | |
Description: "CloudFront Distribution ID" | |
Value: !Ref Distribution |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment