Building Core 567 - Part 2
Second and final part of the blog post series about buidling this website. In this installment, I’ll focus on 2 things:
- The AWS infrastructure that serves this website (bog standard setup, I won’t spend too much time describing this)
- How artifacts are deployed to AWS S3 using Github Actions
AWS infrastructure
The infrastructure that backs this website is quite simple:
- Artifacts are stored in an S3 bucket.
- Cloudfronts is used in front of S3 to provide caching and to allow users to locally consume content
- Route 53 is used to setup the necessary DNS records to route traffic from core567.com to the above Cloudfront distribution
There’s really nothing special to this setup. If you want to replicate it, follow this excellent tutorial published by Amazon: Tutorial: Configuring a static website using a custom domain registered with Route 53.
Deploying to S3 using Github Actions
I will spend more words explaining how deployments to AWS are setup, since I was not satisfied with the documentation I found online.
Let’s start by showing the full Github Actions workflow yaml (the keen eyed will notice that I’m storing AWS ARNs and identifiers as variables to avoid leaking them to the public):
name: Build website and publish to S3
on:
push:
branches: [ "main" ]
env:
node-version: 22.x
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{env.node-version}}
uses: actions/setup-node@v4
with:
node-version: ${{env.node-version}}
cache: 'npm'
- run: npm install
- run: npm run build:11ty
- name: Upload website files
uses: actions/upload-artifact@v4
with:
name: website
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Download website files
uses: actions/download-artifact@v4
with:
name: website
path: dist
- name: Setup AWS CLI
uses: aws-actions/configure-aws-credentials@v4
with:
audience: sts.amazonaws.com
aws-region: us-east-1
role-to-assume: ${{vars.CORE567_UPLOAD_ROLE_ARN}}
- name: Upload files to S3 bucket
run: aws s3 sync dist ${{vars.CORE567_BUCKET_NAME}} --delete
- name: Invalidate Cloudfront cache
run: aws cloudfront create-invalidation --distribution-id ${{vars.CORE567_DISTRIBUTION_ID}} --paths '/*'
As you can see, I decided to split the workflow in two jobs: one to build
the website, and another one to deploy
it to AWS. I could have coalesced both jobs into one, but I wanted to keep them separate to have better visual feedback about the result of each major phase in the deployment process.
The build
job is nothing fancy: checks out the commit that triggered the action (actions/checkout@v4
), sets up Node.js and npm caching (actions/setup-node@v4
), buids the website artifacts (npm install
and npm run build:11ty
), and lastly makes them available to the next build job (actions/upload-artifact@v4
).
The deploy
job is where things start getting more interesting, for two reasons:
- I’m using the AWS CLI directly. This is important because despite what many Google results might lead you to believe, you don’t need to get any third-party action from the Marketplace.
- I’m authenticating to AWS using OIDC, which is more secure than just using an AWS Access Key as it doesn’t require you to share long-lived AWS credentials with Github.
Let’s dissect the deploy
job to see what each part of the configuration does:
needs: build
This just declares a dependency between the two build jobs. It instructs Github Actions that deploy
needs to run after the build
job.
permissions:
id-token: write
id-token: write
instructs Github Actions that this job is authorised to generate an OIDC JWT ID Token. This is a crucial piece of configuration since we need this token later when authenticating to AWS.
- name: Download website files
uses: actions/download-artifact@v4
with:
name: website
path: dist
actions/download-artifact@v4
just downloads the website artifacts that we built in the build
step.
- name: Setup AWS CLI
uses: aws-actions/configure-aws-credentials@v4
with:
audience: sts.amazonaws.com
aws-region: us-east-1
role-to-assume: ${{vars.CORE567_UPLOAD_ROLE_ARN}}
This is where most of the magic happens. I will delve into the crucial points here as the official configure-aws-credentials
documentation can be a bit confusing.
aws-actions/configure-aws-credentials@v4
instructs how the AWS CLI / Github Actions authenticate with your AWS Account. role-to-assume
indicates that we want to authenticate via OIDC, and assume the specified role.
In order for this to work there’s some configuration you’ll need to perform on the AWS side:
- Setup Github Actions / AWS OIDC federation
- Create a role that grants permission to the AWS resources we need to access during the deployment
Setting up Github Actions / AWS OIDC federation creates a trust relationship between Github and AWS, essentially allowing Github Actions to authenticate directly with your AWS account. This is what allows you to avoid storing AWS authentication credentials in Github. AWS provides a convenient Cloudformation template to automate this part of the setup .
The last piece of the puzzle is the AWS role. This is the role that Github Actions will assume in order to call the AWS APIs required to deploy the website. Here’s how it’s configured on my AWS account:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<aws-account-id>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:guidorota/core567-website:ref:refs/heads/main"
}
}
}
]
}
As you can see you can restric which Github organization, repository, and even branch is allowed to assume this role. This is yet another advantage of OIDC authentication compared to other AuthN/Z mechanisms.
This role grants the following permission policies, which allow us to use the AWS CLI to perform CRUD operation in S3 and invalidate Cloudfront CDN caches:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::<bucket-name>",
"arn:aws:s3:::<bucket-name>/*"
]
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation"
],
"Resource": [
"arn:aws:cloudfront::<aws-account-id>:distribution/<cloudfront-distribution-id>"
]
}
]
}
Ok, quick recap. After all of the above, our Github Actions workflow can:
- Authenticate with your AWS account using OIDC
- Assume the specified role and get a temporary access token to call AWS APIs
All that’s left to do is calling these AWS APIs:
- name: Upload files to S3 bucket
run: aws s3 sync dist ${{vars.CORE567_BUCKET_NAME}} --delete
- name: Invalidate Cloudfront cache
run: aws cloudfront create-invalidation --distribution-id ${{vars.CORE567_DISTRIBUTION_ID}} --paths '/*'
Nothing fancy here, I’m just replacing all content in the S3 bucket that backs this website. It’s a simple personal website so I’m not implementing any fancy rollout strategy. Similarly, I opted to just invalidate all CDN caches at every deployment instead of overcomplicating things by creating unique resource hashes etc.