In this post I want to show you how to set up a production-grade static site in AWS. The catch? We aren’t going to perform any click-ops in AWS console and eliminate any manual steps.
Instead we are going to build “Living Infrastruture” using Infrastructure as Code (IaC). To make this enterprise ready, we will decouple the setup into three distinct layers. This ensures that a change to our CSS won’t accidently trigger a change to our DNS settings.
The Roadmap
Building this involves several steps and we can break them down into three stages.Phase 1: The Global Foundation
This is the "level 0" layer. These resources are created once and shared across your entire AWS account. This is going to be its own GitHub repo and we will ask CloudFormation to create the following resources for us.- Create a GitHub Connection: The bridge between your code and AWS.
- Create a Route 53 Hosted Zone (Because my registrar is not AWS)
- Set up an SSL Certificate
- Create a Git Sync IAM Role: The role that allows CloudFormation to build the resources on your behalf.
Note: If you have bought your domain using AWS then a Hosted Zone will be created for you.
Phase 2: The Site Infrastructure
This layer is specific to your site. It uses resources created by the global Foundation in addition to creating its own resources. This is going to be its own repo.- Create an S3 Bucket (Where the website files will live)
- Create a Bucket Policy and apply it our S3 bucket.
- Create an artifact S3 bucket.
- Create a Code pipeline (Automated way to move the code from GitHub to S3)
- Create a service role for the above CodePipeline to make use of.
- Create a Static site Pipeline.
- Create a CloudFront Origin Access Control (OAC).
- Create a CloudFront distribution
- Create a domain record.
Phase 3: Create the Stack
In the last Phase we will create the Stack in AWS Console using Sync from Git.
Introducing the Architect: AWS CloudFormation
Building your foundation is like drawing a blueprint for a house. You don't just start laying bricks, you define where the walls go, where the pipes run, and who has the keys. In AWS, our "blueprint" language of choice is CloudFormation.To automate everything, we use AWS CloudFormation. It allows us to write a simple text file (in YAML) that tells AWS exactly what we want.
Instead of logging into AWS Console and clicking “Create Bucket” you give CloudFormation a script. It reads the script, realizes you need a bucket, a certificate, and a DNS record, and then it builds them for you in perfect order.
Phase 1: Setting up the Global Foundation
We will start by creating a new Global Resources repository. This is the bedrock of our infrastructure. These are "global" because these resoucres can be consumed by any other resource by our infrastructure.We are going to build this template step by step so you can see how each piece of the puzzle fits together.
This repo contains two files:
- The Template This is your YAML file, the source of truth.
- The Stack This is what CloudFormation creates. When you “deploy” your template, AWS groups all
the resources under one stack.
Step 1: The GitHub Connection
First, we need to let AWS talk to GitHub. We begin by creating a new YAML file in our repo. Let's call this file global-infra.yaml.AWSTemplateFormatVersion: 2010-09-09
Description: Foundational resources for yourdomain.com - Includes DNS (Route 53), SSL (ACM), GitHub Connections, and IAM roles for Git Sync.
The file starts by looking something like this
Now we are ready to add resources to this file. CloudFormation will read this file and create the resources for us.
Resources:
SharedGitHubConnection:
Type: AWS::CodeConnections::Connection
Properties:
ConnectionName: github-org-connection
ProviderType: GitHub
Here we are asking CloudFormation to create a GitHub connection resource.
Now is a good time to install the AWS connector for GitHub and complete the necessary set up.
The next things our site will need is an SSL certificate, so lets ask CloudFormation to create that for us.
Resources:
YourDomainCertificate:
Type: 'AWS::CertificateManager::Certificate'
Properties:
DomainName: yourdomain.com
SubjectAlternativeNames:
- www.yourdomain.com
ValidationMethod: DNS
DomainValidationOptions:
- DomainName: yourdomain.com
HostedZoneId: !Ref YourDomainHostedZone
- DomainName: www.yourdomain.com
HostedZoneId: !Ref YourDomainHostedZone
Here we create a new resource AWS::CertificateManager::Certificate and label it YourDomainCertificate so we can reference it later.
Now we need a place to manage our domain records. In AWS this is called a Hosted Zone.
Lets add the Hosted Zone as a resource in our script.
Resources:
YourDomainHostedZone:
Type: 'AWS::Route53::HostedZone'
Properties:
Name: yourdomain.com
If you bought your domain on AWS, a Hosted Zone was likely created for you. To keep everything automated we are going to import or recreate that zone in our script so that our code has full control over our DNS. Since I didn’t buy my domain name on AWS I need to create a Hosted Zone. `
If your registrar is AWS then AWS has already created your HostedZone and all we need is a parameter
Parameters:
YourDomainHostedZoneId:
Type: String
Description: The ID of the Hosted Zone created by AWS when the domain was purchased.
Resources:
YourDomainCertificate:
Type: 'AWS::CertificateManager::Certificate'
Properties:
DomainName: yourdomain.me
SubjectAlternativeNames:
- www.yourdomain.me
ValidationMethod: DNS
DomainValidationOptions:
- DomainName: yourdomain.me
HostedZoneId: !Ref YourDomainHostedZoneId
- DomainName: www.yourdomain.me
HostedZoneId: !Ref YourDomainHostedZoneId
The parameter is populated in your deployment file which we will create later.
CloudFormation doesn’t have any access to create resoucres on your behalf so we need to create an IAM Role with proper permissions and policies so it can create all our resources for us.
Resources:
CloudFormationGitSyncRole:
Type: 'AWS::IAM::Role'
Properties:
RoleName: CloudFormationGitSyncRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- cloudformation.sync.codeconnections.amazonaws.com
- cloudformation.amazonaws.com
Action: 'sts:AssumeRole'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AWSCloudFormationFullAccess
- arn:aws:iam::aws:policy/AmazonS3FullAccess
- arn:aws:iam::aws:policy/IAMFullAccess
- arn:aws:iam::aws:policy/AWSCodePipeline_FullAccess
Policies:
- PolicyName: GitSyncExtraPermissions
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: EventBridgeManagement
Effect: Allow
Action:
- 'events:PutRule'
- 'events:PutTargets'
- 'events:DescribeRule'
Resource: '*'
- Sid: PassConnectionPermission
Effect: Allow
Action:
- 'codeconnections:PassConnection'
- 'codeconnections:UseConnection'
- 'codestar-connections:PassConnection'
- 'codestar-connections:UseConnection'
Resource: !Sub 'arn:aws:codeconnections:${AWS::Region}:${AWS::AccountId}:connection/*'
- Sid: InfrastructurePermissions
Effect: Allow
Action:
- 'cloudfront:CreateOriginAccessControl'
- 'cloudfront:GetOriginAccessControl'
- 'cloudfront:UpdateOriginAccessControl'
- 'cloudfront:DeleteOriginAccessControl'
- 'cloudfront:CreateDistribution'
- 'cloudfront:GetDistribution'
- 'cloudfront:UpdateDistribution'
- 'cloudfront:DeleteDistribution'
- 'cloudfront:TagResource'
- 'route53:CreateHostedZone'
- 'route53:GetHostedZone'
- 'route53:ChangeResourceRecordSets'
- 'route53:ListResourceRecordSets'
- 'route53:GetChange'
- 'acm:DescribeCertificate'
- 'acm:ListCertificates'
- 'acm:RequestCertificate'
Resource: '*'
This role creates the necessary permissions and applies the appropriate policies to let CloudFormation access our GitHub connection, manage files in our S3 bucket, manage SSL certificates, and let it use the CodePipeline which we will setup in our site infrastructure repository.
The next thing to do is to export the Arn
of the resoucres we created and the Hosted Zone Id. We do this so another stack can reference the resource and
start using it.
Outputs:
ExportedConnectionArn:
Value: !Ref SharedGitHubConnection
Export:
Name: "GlobalResourcesStack-GitHubConnectionArn"
CertificateArn:
Value: !Ref YourDomainCertificate
Export:
Name: GlobalResources-CertificateArn
HostedZoneId:
Value: !Ref YourDomainHostedZone
Export:
Name: GlobalResources-HostedZoneId
Here is a reference to the complete file if your registrar is not AWS. if your registrar is AWS then reference this file.
The last thing we need to do in this stack is to create a deployment file. This is arguably the simplest file in our setup.
template-file-path: global-infra.yaml
Name this file deployment-file.yaml
If Route 53 is the registrar of your domain then you need to set the parameter in your deployment file
template-file-path: global-infra.yaml
parameters:
YourDomainHostedZoneId: YourHostedZoneID
Find your hosted Zone Id by navigating to Route 53 -> Hosted Zones -> Select your zone -> Expand Hosted zone ID -> Copy Hosted zone ID and paste it
Lets push this to GitHub and now we have completed our global resources repo. Lets move on to the next phase which will be to create our static site stack.
Phase 2: Setting up Static Site Infrastructure
Now we are ready to create our infrastructure which will create the S3 bucket, code pipeline, cloudfront, and a domain record needed to bring our site to the public.We will begin by creating a new repo and inside this repo we will create a file called site-infra.yaml and lets add the following to the top of the file
AWSTemplateFormatVersion: 2010-09-09
Description: Infrastructure for yourdomain.com - S3 Hosting, CloudFront CDN, and CI/CD Pipeline.
Now we need to create a resource which will tell CloudFormation to create an S3 bucket for us
Resources:
S3WebsiteBucket:
Type: 'AWS::S3::Bucket'
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
VersioningConfiguration:
Status: Enabled
LifecycleConfiguration:
Rules:
- Id: AutoCleanupOldVersions
Status: Enabled
NoncurrentVersionExpiration:
NoncurrentDays: 30
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
This tells CloudFormation to create an S3 bucket and make it private. We are keeping the bucket private and only allowing CloudFront to see the files via Origin Access Control (OAC). Versioning is enabled for this bucket so we can perform rollbacks easily. The old versions are kept for 30 days.
Now lets apply a policy to the bucket to allow all the files inside the bucket to be read publicly.
Resources:
BucketPolicy:
Type: 'AWS::S3::BucketPolicy'
Properties:
Bucket: !Ref S3WebsiteBucket
PolicyDocument:
Id: MyPolicy
Version: 2012-10-17
Statement:
- Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: 's3:GetObject'
Resource: !Sub '${S3WebsiteBucket.Arn}/*'
Condition:
StringEquals:
# Wildcard used to prevent circular dependency errors during first creation
AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"
Here we reference the bucket using the
!Reftemplate.
CloudFormation cannot get the code from your repo and deploy directly to the public bucket. It needs another intermediate bucket where it will store the zipped version of our git repo. So lets create that bucket.
PipelineArtifactBucket:
Type: 'AWS::S3::Bucket'
Properties:
VersioningConfiguration:
Status: Enabled
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
Here we create our Artifact bucket where the zip version of the site will live.
Next we create an IAM Role that can give permissions to the pipeline we will create. We arn’t giving it full administrative access instead, we are practicing the principle of least privilege. We give it only the specific permissions it needs, the ability to use our GitHub connection and the ability to read/write to exactly two S3 buckets.
CodePipelineServiceRole:
Type: 'AWS::IAM::Role'
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- codepipeline.amazonaws.com
Action: 'sts:AssumeRole'
Policies:
- PolicyName: PipelineAccessPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- s3:GetObject
- s3:GetObjectVersion
- s3:GetBucketVersioning
- s3:PutObject
- s3:ListBucket
- s3:DeleteObject
Resource:
- !GetAtt S3WebsiteBucket.Arn
- !GetAtt PipelineArtifactBucket.Arn
- !Sub '${S3WebsiteBucket.Arn}/*'
- !Sub '${PipelineArtifactBucket.Arn}/*'
- Effect: Allow
Action: 'codestar-connections:UseConnection'
Resource: !ImportValue "GlobalResourcesStack-GitHubConnectionArn"
This IAM role is a permanent part of our infrastructure. It sits quietly in our account, granting the pipeline the power to act only when a code change triggers a deployment.
With our security pass (IAM Role) in place, we can now build the conveyor belt (the pipeline). Now lets create the pipeline. This is how it looks like
StaticSitePipeline:
Type: 'AWS::CodePipeline::Pipeline'
Properties:
RoleArn: !GetAtt CodePipelineServiceRole.Arn
ArtifactStore:
Type: S3
Location: !Ref PipelineArtifactBucket
Stages:
- Name: Source
Actions:
- Name: GitHubSource
ActionTypeId:
Category: Source
Owner: AWS
Provider: CodeStarSourceConnection
Version: '1'
OutputArtifacts:
- Name: SourceArtifact
Configuration:
ConnectionArn: !ImportValue "GlobalResourcesStack-GitHubConnectionArn"
FullRepositoryId: github_username/yourrepo
BranchName: main
OutputArtifactFormat: CODE_ZIP
- Name: Deploy
Actions:
- Name: S3Deploy
ActionTypeId:
Category: Deploy
Owner: AWS
Provider: S3
Version: '1'
InputArtifacts:
- Name: SourceArtifact
Configuration:
BucketName: !Ref S3WebsiteBucket
Extract: 'true'
CacheControl: max-age=0,no-cache,no-store,must-revalidate
The FullRepositoryId is where you specify your static site repo.
Here we create the pipeline which uses the role we created earlier. We have defined two stages: source and deploy. It uses the GitHub connection created by the global resources stack and creates a zipped version of the site in the S3 artifact bucket. The next stage is the deploy stage and it takes the zipped file from the artifact buccket, unzips it and moves the files in the public S3 bucket.
Now that our files are automatically moving into an S3 bucket we are not done yet. S3 bucket we created does not allow public access to the files and directories because we are going to use Origin Access control and to enable lets add that resource
Resources:
CloudFrontOAC:
Type: 'AWS::CloudFront::OriginAccessControl'
Properties:
OriginAccessControlConfig:
Description: "OAC for yourdomain.com S3 Bucket"
Name: !Sub "${AWS::StackName}-OAC"
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
Lets add a CloudFront so we can use our SSL certificate. The update looks like this
Resources:
CloudFrontDistribution:
Type: 'AWS::CloudFront::Distribution'
Properties:
DistributionConfig:
Aliases:
- yourdomain.com
- www.yourdomain.com
DefaultRootObject: index.html
Enabled: true
Origins:
- DomainName: !GetAtt S3WebsiteBucket.RegionalDomainName
Id: S3Origin
OriginAccessControlId: !GetAtt CloudFrontOAC.Id
S3OriginConfig:
OriginAccessIdentity: ""
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
ForwardedValues:
QueryString: false
ViewerCertificate:
AcmCertificateArn: !ImportValue GlobalResources-CertificateArn
SslSupportMethod: sni-only
CloudFront sits in front of our S3 bucket and we have also added an SSL certificate.
We have our files in S3, our automation pipeline is ready, and CloudFront is standing by to serve our site
securely via SSL. The final piece of the puzzle is telling the internet that yourdomain.com belongs to our CloudFront.
DomainRecord:
Type: 'AWS::Route53::RecordSet'
Properties:
HostedZoneId: !ImportValue GlobalResources-HostedZoneId
Name: yourdomain.com
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2
You’ll notice a strange ID Z2FDTNDATAQYW2. Don’t worry, I didn’t leak my private ID! This is a universal constant provided by AWS to represent CloudFront. It’s the same for everyone.
With this step added our Phase 2 is complete. You have:
- S3: Storage
- IAM Role: Permissions.
- CodePipeline: Automation.
- CloudFront: Security/Speed.
- Route53: The Address.
Here is a reference to the final infrastructure file.
Now we have to create a deployment file for this stack and it looks like this. Create a new file called deployment-file.yaml
template-file-path: site-infra.yaml
Lets push this to GitHub and now we have completed our static site infrastructure repo. Lets move on to the next phase which will be to create our stack in AWS.
Phase 3: Create the Stack
Now is a good time to create your two stacks in AWS for the two git repositories.Create the Global infrastructure stack
We have to deploy our stacks in order. The first one we need to deploy is the global infrastructure repository and then the site infrastructure repository which will create the code pipeline to deploy our static site.
Lets create our global infrastructure stack in AWS Console.
Login to AWS Console -> CloudFormation -> Stacks -> Create Stack -> With new resources (standard) and then choose Choose an existing template under Prepare template. Then select Upload a template file and seletc global-infra.yaml from your global infra repo. We have to do this step so AWS creates the GitHub connection.
Enter your stack name something like Global Resources/Stack. Click next.
On the next page scroll down and check I acknowledge that AWS CloudFormation might create IAM resources with custom names.
Click next. On the next page click on Submit to create your stack.
Once your stack is created navigate to CodePipeline -> Settings -> Connections and find your connection called
github-org-connection and select the connection and the Update pending connection button will be enabled.
Click that button which will open up a popup with a button Connect. Lets click on the Connect button to enable
the GitHub connection. Now the status for your GitHub connection should show as Available.
Navigate back to your stack, CloudFormation -> Stacks -> and you should see your stack in CREATE_IN_PROGRESS to complete this step navigate to Route53 and under Hosted Zone we should see one HostedZone. Click on Hosted Zones and then click on your domain. For type NS you should see 4 values under Route traffic to, we need to copy these values and update the nameservers where you bought your domain. In my case this is in hover.com.
Once the nameservers are updated navigate to CertificateManager and we should see one of our certificate as pending validation. Click on the certificate and then click on Create records in Route 53. Then click on Create records. After about 15 minutes the certificate should say Available.
Once the certificate is available lets complete the setup by enabling Sync from Git so this Stack becomes the living Infrastruture as Code (IaC).
Note: If you bought the domain in Route 53 then you do not need to complete the above steps. AWS will complete them for you in 15-30 minutes.
Lets also Sync from Git for the global repository so the stack becomes a living infrastructure as code stack.
Navigate to CloudFormation -> Stacks -> Select your global stack -> Click on Git sync
Click on Connect -> Select IAM role name and select the role CloudFormationGitSyncRole created by our global stack. Click on next.
Select Link a Git repository -> GitHub -> under Connection -> select github-org-connection -> select your global infra repo -> select your branch
-> under deployment file path enter deployment-file.yaml.
Under IAM Role select Existing IAM Role and then select the role CloudFormationGitSyncRole.
Under Template file path enter global-infra.yaml which is the file we created the stack with.
Complete the configuration by clicking on Create configuration.
Once the connection is enabled, CloudFormation will update the stack.
Now we are ready to create the infrastructure stack.
Create the Static site infrastructure stack
Navigate to CloudFormation -> Stacks -> Create stack -> With new resources(standard).
Select Choose an existing template -> Under specify template select Sync from Git and click on next.
Enter your stack name and select I am providing my own file in the repository.
Select Link a Git repository.
Select GitHub -> your connection -> select your infra repo -> select your branch -> and under deployment file path enter deployment-file.yaml.
Select an Existing IAM role and select CloudFormationGitSyncRole. Enter site-infra.yaml under Template file path and click on next.
Under permissions select CloudFormationGitSyncRole as the IAM role then click on next and on the next screen click on submit.
Complete the setup and CloudFormation will begin creating our resources and our static site is now live.
Once the stack is created you should see a new code pipeline under AWS Console -> CodePipeline -> Pipelines.
Any changes done to the static site repository will trigger the pipeline to run and your static site will be updated.
Git References
- Git repository for the global infrastructure.- Git repository for the static site infrastructure.
Resources
- Working with CloudFormation- How Git sync works with CloudFormation
- AWS::CodeConnections::Connection
- AWS::CertificateManager::Certificate
- AWS::Route53::HostedZone
- AWS::IAM::Role
- AWS::S3::Bucket
- UpdateReplacePolicy
- DeletionPolicy
- AWS::CloudFront::OriginAccessControl
- AWS::CloudFront::Distribution
- Fn::Ref
- Fn::Sub
- Route 53 template snippets