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:
- Create and configure an s3 bucket
- Set up TLS certificates for CloudFront
- Create the CloudFront distribution
- Configure IAM roles and access
- Create a service account for Github
- Get Github Actions working
- 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 consoleS3_BUCKET- The S3 bucket name, for examplemy-website-bucketIAM_ROLE- The ARN of the IAM service account, for examplearn: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:
- AWS credentials are obtained using OpenID Connect
- The static site is built, in this case with
hugo - The built assets are uploaded to S3
- 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 ;)