Deploy to Cloudfront from GitHub using OpenID Connect

Deploy to Cloudfront from GitHub using OpenID Connect

A common usage of CI/CD tools today is to build and deploy a static website to a CDN.

This has many advantages over the old way (like running Wordpress), such as security, cost, and flexibility. Developers love the ability to use any crazy JavaScript library they want on the client side, Sysadmins love not having another PHP server to feed and water, and accountants love the bill!

However, one common issue has been the common practice of hard-coding AWS API keys into CI servers, or worse, committing them into a repository.

Fortunately, there’s an easy way around this, by using OpenID Connect federation to securely and safely authenticate CI agents between cloud systems. With this approach, you never have to worry about rotating keys, tracking down lost credentials, or revoking access from somebody no longer on the project. CI/CD programs like GitHub can seamlessly work away in the background without any fuss, and everybody’s happy!

                                                                         
      ┌──────────┐       ┌───────────┐               ┌───────────────────┐   
      │CloudFront│       │ S3 Bucket │               │     AWS IAM       │   
      │   Site   ┼──────►│           │               │  STS Trust Policy │   
      └──────────┘       └───────────┘               └─────┬─────────────┘   
            ▲                  ▲                           │        ▲        
            │                  │                           │        │        
            │                  │                           │        │        
            │                  │                           │        │        
            │  ┌──────────┐    │                           │        │        
            └──┤ IAM Role ┼────┘                           │        │        
               └──────────┘                                │        │        
                     ▲                                     │        │        
                     │                                     │        │        
                     │                                     │        │        
                     │                                     ▼        │        
        ┌────────────┴───────────┐                   ┌──────────────┴─────┐  
        │  GitHub Actions Runner │                   │ GitHub ID Provider │  
        └────────────────────────┘                   └────────────────────┘  

Here’s how:

  1. Create and configure an s3 bucket
  2. Set up TLS certificates for CloudFront
  3. Create the CloudFront distribution
  4. Configure IAM roles and access
  5. Create a service account for Github
  6. Get Github Actions working
  7. Deploy a website!

Almost all of this will be done with Terraform, though OpenTofu should work just as well.

Getting set up

First, a vars.tf file should be made with the input variable. Alternatively, all of this config can be made into a module, and this variable may be passed in.

variable "website_url" {
    type    = string
    default = "my-cool-website.com"
}

This also works on the assumption that there exists a public Route53 zone that is a primary writable DNS zone. I often recommend to people that if their main DNS hosting is in a service like CloudFlare, a sub-zone delegation is created for the specific parts that exist within AWS. It makes stuff like this much easier by not having to fiddle with multiple providers and clouds at once.

Using a data resource, we can use that previously created DNS zone to integrate with CloudFront:

data "aws_route53_zone" "website" {
  name         = "${var.website_url}."
  private_zone = false
}

S3 bucket

The files that make up the static site will be stored in S3. A very simple bucket will be configured for this.

resource "aws_s3_bucket" "website" {
    bucket = "my-website-bucket"
}

Even though the actual website will be public, the bucket itself is fully private. This configuration ensures that only CloudFront will be able to access the site.

resource "aws_s3_bucket_public_access_block" "website_public_block" {
  bucket = aws_s3_bucket.website.id
  block_public_acls       = true
  block_public_policy     = true
  restrict_public_buckets = true
  ignore_public_acls      = true
}

resource "aws_cloudfront_origin_access_identity" "website_oai" {
  comment = "Origin identity - Static Website"
}

data "aws_iam_policy_document" "website_bucket_policy" {
  statement {
    actions = ["s3:GetObject"]
    resources = [
      aws_s3_bucket.website.arn,
      "${aws_s3_bucket.website.arn}/*"
    ]
    principals {
      type        = "AWS"
      identifiers = [aws_cloudfront_origin_access_identity.website_oai.iam_arn]
    }
  }
}

resource "aws_s3_bucket_policy" "website_bucket_policy" {
  bucket = aws_s3_bucket.website.id
  policy = data.aws_iam_policy_document.website_bucket_policy.json
}

CloudFront TLS configuration

Getting CloudFront to handle TLS for a custom domain is very important, but has a couple “gotchas.”

While most of AWS is strictly separated by region, CloudFront can only load certificates that exist in us-east-1. Since our site is based in ca-central-1 this could pose a problem!

To remedy this, we create a special AWS provider that is only used for creating the certificate:

provider "aws" {
    alias  = "us-east"
    region = "us-east-1"
}

Then, a certificate request can be made. When validated, this certificate will allow us to enable HTTPS on our website via CloudFront.

resource "aws_acm_certificate" "cloudfront_cert_website" {
  domain_name       = var.website_url
  validation_method = "DNS"
  provider          = aws.us-east
}

resource "aws_route53_record" "cloudfront_cert_dvo_website" {
  for_each = {
    for dvo in aws_acm_certificate.cloudfront_cert_website.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.website.zone_id
}

Note that the ACM cert resource has a special provider attribute - that’s doing the important job of creating the certificate in the right region for CloudFront.

Certificates can often take a couple minutes, so do not despair if this doesn’t work right away!

CloudFront distribution

The distribution itself is fairly simple, since all it is doing is directing incoming web requests to its backend datasource in the S3 bucket.

resource "aws_cloudfront_distribution" "website" {
  enabled             = true
  is_ipv6_enabled     = true
  comment             = "Static Website"
  default_root_object = "index.html"
  price_class         = "PriceClass_100"

  aliases = [
    var.website_url
  ]

  origin {
    domain_name = aws_s3_bucket.website.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.website.id
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.website_oai.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    target_origin_id       = aws_s3_bucket.website.id
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.cloudfront_cert_website.arn
    minimum_protocol_version = "TLSv1.2_2021"
    ssl_support_method = "sni-only"
  }
}

There is a lot more that may be configured in CloudFront, so be sure to check out the provider documentation before getting your hands dirty.

Then, finally, the public hostname for the site can be directed to CloudFront for both IPv4 and IPv6 using ALIAS records:

resource "aws_route53_record" "website_A" {
  zone_id = data.aws_route53_zone.website.zone_id
  name = var.website_url
  type = "A"
  alias {
    name    = aws_cloudfront_distribution.website.domain_name
    zone_id = aws_cloudfront_distribution.website.hosted_zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "website_AAAA" {
  zone_id = data.aws_route53_zone.website.zone_id
  name = var.website_url
  type = "AAAA"
  alias {
    name    = aws_cloudfront_distribution.website.domain_name
    zone_id = aws_cloudfront_distribution.website.hosted_zone_id
    evaluate_target_health = true
  }
}

At this point, the site deployment itself is done! If you just upload a .html file to the S3 bucket, it will now show up on your public domain.

If you’re willing to manually upload files to S3 every time the site changes, stop here… For everybody else, let’s get CI hooked up!

Identity Federation

This resource defines the Identity Provider that IAM will use. These often authenticate users for Single-Sign-On, but in this case it is for robots.

data "tls_certificate" "github_oidc" {
  url = "https://token.actions.githubusercontent.com"
}

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
  client_id_list = [
    "sts.amazonaws.com",
  ]
  thumbprint_list = [ data.tls_certificate.github_oidc.certificates[0].sha1_fingerprint ]
}

Next, the policies for IAM are set up. This instructs IAM how to verify the identity of the GitHub actions runner, restricting access to only our specific repository.

The most important part of this configuration is near the bottom, where the repository name is configured in the condition section. This must match exactly with the name of the repository that builds and deploys the site. Setting this too broadly can allow unauthorized access from within your organization, or the general public, so be as specific as possible here!

data "aws_iam_policy_document" "github_trust" {
  statement {
    effect = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values = ["sts.amazonaws.com"]
    }

    condition {
      test = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values = ["repo:example/example-project:*"]
    }
  }
}

Site update service account

Next, the permissions policy are set up. This is a list of specific actions that the deployment service account is allowed to do once authenticated. This is all that’s strictly needed for most purposes, but if you use object versions in your workflow some additional privileges will be needed.

Also note that the service account also has access to CloudFront, but only to manage invalidations. This is an important inclusion, since it lets the CI process clear the cache after all files are updated, preventing caching issues for clients and more rapid propagation through CloudFront’s CDN. Do note however that these actions can have a bit of cost, if the files being loaded back into the CDN are very large.

resource "aws_iam_policy" "website_policy" {
  name = "website_service"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket",
        "s3:GetBucketWebsite",
        "s3:ListBucketVersions",
        "s3:GetObjectAttributes",
        "s3:DeleteObject",
        ]
        Resource = [ 
           aws_s3_bucket.website.arn,
          "${aws_s3_bucket.website.arn}/*"
        ] 
      },
      {
        Effect = "Allow"
        Action = [
        "cloudfront:ListInvalidations",
        "cloudfront:GetInvalidation",
        "cloudfront:ListDistributions",
        "cloudfront:CreateInvalidation"
        ]
        Resource = [ 
          aws_cloudfront_distribution.website.arn
        ]
      }
    ]
  })
}

resource "aws_iam_role" "website_role" {
  name = "website_deploy_service"
  assume_role_policy = data.aws_iam_policy_document.github_trust_webpage.json
}

resource "aws_iam_role_policy_attachment" "website_role_attachment" {
  role       = aws_iam_role.website_role.name
  policy_arn = aws_iam_policy.website_policy.arn
}

Github Variables

Now that AWS is set up, we can move over to GitHub to configure our repository. This config can go under “Settings -> Secrets and Variables -> Actions”, or in “Settings -> Environments” depending on if there is a single or multiple versions of the site.

The required variables:

  • CF_DIST - The CloudFront distribution ID, which can be found on the AWS console
  • S3_BUCKET - The S3 bucket name, for example my-website-bucket
  • IAM_ROLE - The ARN of the IAM service account, for example arn:aws:iam::12345:role:website_deploy_service

Github Actions

For this example the site is built with Hugo, but any static site generator tool would work basically the same way.

This can be broadly modified for the specific repository, but the important part here is the order of the steps:

  1. AWS credentials are obtained using OpenID Connect
  2. The static site is built, in this case with hugo
  3. The built assets are uploaded to S3
  4. The CloudFront cache is reloaded, forcing the new content to be retrieved from S3

An example:

env:
  AWS_REGION : "ca-central-1"

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.IAM_ROLE }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Build site
        run: hugo

      - name: S3 Upload
        run: aws s3 sync --delete ./public/ s3://${{ vars.S3_BUCKET }}/

      - name: CloudFront Cache reload
        run: aws cloudfront create-invalidation --distribution-id "${{ vars.CF_DIST }}" --paths "/*" 

When run, the whole process of updating the live site should take less than a minute, though that will depend on the size and complexity of the site. For something as simple as my site, it would be typically about 25 seconds.

When all put together, the result is greater than the sum of its parts! There are plenty of batteries-included solutions, but the cost of convenience is much greater than just putting something like this together. Hopefully it will help others built and deploy sites of their own!

Of course, you can just as easily do all of this on a single Debian server if you know how ;)