Automate Hugo deploys to S3 and CloudFront with GitHub Actions OIDC

Table of Contents
At the end of my Hugo on S3 and CloudFront article, I wrote “Lazy, but it works!” about running Hugo and aws s3 sync from my laptop. After some time, I became too lazy also for that :)
Publishing was not difficult, but I needed a machine with Hugo installed and my AWS profile configured. The right Hugo version was another thing to remember, and it was a small manual procedure that worked until I didn’t do it for a while.
Now, after a merge to master, GitHub Actions builds the site, obtains temporary AWS credentials with OIDC, and uploads the public/ directory to S3. When the upload is finished, it creates a CloudFront invalidation. Since I’m using temporary credentials with the OIDC, AWS credentials are not stored anywhere.
For the articles, I still use a branch and a pull request. I review the result and merge it when I’m happy. I didn’t automate my questionable editorial decisions.
Why I stopped deploying Hugo from my laptop #
I never had an issue with my old way of working, this time I have no story to tell about a lost website or a weekend spent recovering files. I simply wanted to remove a manual step from something that I was already doing in a repository (and I knew it could be useful for my future self).
A local deploy has assumptions that are easy to forget. Congo is a Hugo module, so Go is needed too, and the AWS CLI has to use the correct profile. With one laptop this is fine, but the configuration is not with the rest of the site.
In the previous article, the old and dirty deployment was just this:
hugo
aws s3 sync --delete public/ s3://YOUR_BUCKET/
For a website updated from one computer, this is still a good solution. Don’t create a pipeline only because pipelines are nice. I wanted someone else to merge a reviewed article without asking me to publish it later or sharing my AWS credentials.
Now the Hugo version and deploy commands are in the repository. I don’t have to remember which package I installed months before.
What the pipeline does on every merge to master #
The file .github/workflows/deploy.yml starts on a push to master. After the checkout, it prepares Go and Hugo extended, then runs hugo --minify with bassandbytes/ as working directory. If the build works, it authenticates to AWS and synchronizes the generated public/ directory.
I also enabled workflow_dispatch to be able to deploy the same content again without creating an empty commit, and added a condition to the job to consider only master:
on:
push:
branches: [master]
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
defaults:
run:
working-directory: bassandbytes
The if checks that the selected ref is master, so a manual run cannot start from another branch. The IAM role also restricts the branch, as I will see below.
I use the setting cancel-in-progress: false to prevent GitHub from interrupting an upload. The deploy-production concurrency group avoids parallel runs and deploys of the website.
How GitHub Actions authenticates to AWS with OIDC (no access keys) #
As I said, I didn’t want to use long-lived static AWS credentials in the repository secrets. With id-token: write, GitHub Actions can request an OIDC token that identifies the repository running the workflow. I only set the role to use in the AWS_ROLE_ARN secret in the repo.
The aws-actions/configure-aws-credentials action calls AssumeRoleWithWebIdentity with the token. AWS returns temporary credentials for the role. In the workflow, this is only one step:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
role-session-name: github-actions-blog-deploy
The role ARN is not a password. Its trust policy tells AWS which tokens can assume it. This is my condition:
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}
I set GitHubBranch to master, so the subject must identify my repository and that exact branch; with this mechanism, a token from another repository or from a pull request branch in the same repository doesn’t match. If I were to use repo:YOUR_ORG/YOUR_REPO:*, it would have been easier to write but would also be broader than what this deploy needs.
Least-privilege permissions on the deploy role #
The role policy allows listing YOUR_BUCKET and managing the objects below YOUR_BUCKET/*. I also included the multipart permissions needed by the AWS CLI. On CloudFront, the role can only work with invalidations for YOUR_DISTRIBUTION_ID.
CloudFormation for the GitHub OIDC provider and deploy role #
I created infra/github-oidc-deploy.yaml for the OIDC provider and the deploy role. I used CloudFormation because this is a small and stable part of the infrastructure, and I didn’t want a Terraform state file only for an IAM role. If your website is already managed with Terraform, using it makes sense too.
The relevant part of the template is this:
Resources:
GitHubOIDCProvider:
Type: AWS::IAM::OIDCProvider
Condition: ShouldCreateOIDCProvider
Properties:
Url: https://token.actions.githubusercontent.com
ClientIdList: [sts.amazonaws.com]
ThumbprintList:
- 6938fd4d98bab03faadb97b34396831e3780aea1
- 1c58a3a8518e8759bf075b76b750d4f2df264fcd
GitHubActionsDeployRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Federated: !If
- ShouldCreateOIDCProvider
- !Ref GitHubOIDCProvider
- !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}
Outputs:
RoleArn:
Value: !GetAtt GitHubActionsDeployRole.Arn
The complete file includes the parameters and permissions policy. I deployed the blog-github-oidc-deploy stack with aws cloudformation deploy and CAPABILITY_NAMED_IAM, then copied the RoleArn output into the repository secret.
If your account already has the GitHub provider, set CreateOIDCProvider=false. You can reuse it: each role’s trust policy decides what its workflows can do.
The workflow: Hugo extended, Congo modules, dual S3 sync #
The build uses Hugo extended 0.167.0. I initially looked for 0.167.1, but that version was never published. It’s a small detail, although not a nice error to investigate when you only wanted to publish an article.
env:
HUGO_VERSION: "0.167.0"
steps:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: bassandbytes/go.mod
cache-dependency-path: bassandbytes/go.sum
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
- name: Build
run: hugo --minify
Go is necessary because Congo is a Hugo module, and the site needs the extended edition. Keeping the version in the workflow instead of using the latest release means an upgrade can go through a pull request like any other change.
Why two aws s3 sync passes? #
An aws s3 sync command applies one Cache-Control value to the uploaded objects. HTML pages and feeds should become stale quickly, while for images and other assets I can be more relaxed. So I split the upload.
First, I check that Hugo created a homepage with some content. It’s a simple test, but it can stop the sync if the generated index.html is empty:
test -s public/index.html
aws s3 sync public/ "s3://${S3_BUCKET}/" --delete \
--cache-control "public,max-age=86400" \
--exclude "*.html" --exclude "*.xml" \
--exclude "*.json" --exclude "*.txt"
This command excludes the documents and sends the other files with a cache lifetime of one day. CSS and JavaScript files have hashed names, so I could cache them for longer. Images are different because I sometimes replace a feature image without changing its name. One day is a compromise that avoids keeping a wrong image around for a year.
Then I run another sync only for HTML, XML, JSON and text files:
aws s3 sync public/ "s3://${S3_BUCKET}/" --delete \
--cache-control "public,max-age=60,must-revalidate" \
--exclude "*" \
--include "*.html" --include "*.xml" \
--include "*.json" --include "*.txt"
The order of the filters is important here. The second command excludes everything and then includes the document extensions. Because excluded objects are not considered by --delete, this pass can remove an old page without deleting the images uploaded before. For the same reason, the first pass leaves the HTML files alone.
This took me a moment to reason about, and I prefer to test the filters before a deploy rather than discover an empty bucket afterwards :)
CloudFront invalidation after sync #
After the two uploads, S3 contains the new version but CloudFront can continue serving cached responses. The workflow finishes with:
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*"
I keep "/*" quoted so the shell doesn’t try to expand it. A wildcard invalidation is broad, but for this small website it’s the simplest choice. I also checked the cost before deciding to use it on every deploy.
According to the CloudFront invalidation documentation, the first 1,000 invalidation paths per month in an AWS account are free. A path with a wildcard counts as one path, not as one path for every object it matches. For each deploy, /* therefore uses one unit of the monthly allowance.
Editorial flow: PR review, then master deploys #
I open a pull request and use it for the review, as I did before. Only the merge creates the push to master that starts the deployment, and even a manual run has to refer to master; simply pushing a branch doesn’t publish anything.
This also makes it possible for a reviewer to merge an approved article without having AWS access on their computer. A draft can remain on its branch until it’s ready. For my use case, I didn’t see the need to add a staging website.
Repository access is now part of publishing too. Check branch protection: restricting IAM doesn’t help much if anyone can push directly to master.
What this costs (and what it doesn’t) #
There is no additional server to keep running and I didn’t add a secrets manager. For this Hugo site, the GitHub Actions usage is small. OIDC authentication itself doesn’t have a separate AWS charge.
The S3 and CloudFront costs are the ones described in This website on AWS costs me $0 forever, even if it gets famous. At my publishing frequency, the wildcard invalidations stay within the monthly free allowance.
This is valid for my little blog. The free invalidation paths are counted at account level, so if you run many deployments or already use invalidations for other distributions, you should check the total.
What I’d check if the workflow fails #
When something goes wrong, the failed step usually restricts the search quite a lot. These are the things I would look at:
- The workflow cannot get an OIDC token: make sure
id-token: writeis in the workflow permissions. - AWS rejects the role assumption: check the real token subject against the trust policy, and also verify the selected branch and the value of
AWS_ROLE_ARN. - The upload or the invalidation gets access denied: verify that
s3:DeleteObjectandcloudfront:CreateInvalidationare allowed on the bucket and distribution you are using. - Hugo fails before authentication: check the Go setup and the Congo module. Make sure the installed Hugo is the extended version.
- The deploy finishes but you see old content: look for the invalidation in CloudFront and check whether it has completed.
If you still need to configure the bucket, the CloudFront Function for Hugo subdirectories or the hosting part in general, start from the original hosting article.
How do you deploy your Hugo website? If you have a better Cache-Control approach for images without fingerprinting, let me know in the comments!