16 min read
Living Infrastructure: Building an Enterprise-Grade Static Site with AWS CloudFormation and Git Sync

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.
  1. Create a GitHub Connection: The bridge between your code and AWS.
  2. Create a Route 53 Hosted Zone (Because my registrar is not AWS)
  3. Set up an SSL Certificate
  4. 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.
  1. Create an S3 Bucket (Where the website files will live)
  2. Create a Bucket Policy and apply it our S3 bucket.
  3. Create an artifact S3 bucket.
  4. Create a Code pipeline (Automated way to move the code from GitHub to S3)
  5. Create a service role for the above CodePipeline to make use of.
  6. Create a Static site Pipeline.
  7. Create a CloudFront Origin Access Control (OAC).
  8. Create a CloudFront distribution
  9. 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:

  1. The Template This is your YAML file, the source of truth.
  2. 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 !Ref template.

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:

  1. S3: Storage
  2. IAM Role: Permissions.
  3. CodePipeline: Automation.
  4. CloudFront: Security/Speed.
  5. 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