In a recent article, I wrote about how to set up an Amazon S3 bucket to host a static website using Terraform. Using Amazon S3 for static website hosting provides numerous benefits, from pricing to scalability. It’s an excellent choice if your organization already uses Amazon AWS since it integrates nicely with many other services.
One thing that the previous article didn’t discuss is how to get your static website in your Amazon S3 bucket. If you have a small site with few files you won’t modify frequently, you can make your edits and log in to the AWS console to upload the files directly into your bucket. You can also use the AWS Command Line Interface to copy the files without leaving your console. That would work well for most cases with infrequent updates.
However, what if you have a static website spanning dozens or hundreds of pages or need to update your site often? Manually editing files before uploading them to an Amazon S3 bucket becomes a pain. Additionally, manually uploading each file to your hosting service can be prone to errors. You might forget to update the correct files or accidentally delete existing content.
Thankfully, these days we have an abundance of tools that can help us quickly generate and manage static websites, including sites with hundreds or even thousands of pages. My personal favorite static website generator is Hugo.
Hugo is an open-source static site generator that allows you to assemble entire static websites using a convenient structure, helping you build and maintain sites of any size. It provides multiple features like themes, an organized folder structure to place your content, and blazing-fast build times, even on projects with tons of files. This website you’re visiting now is built using Hugo, and it’s been one of the better experiences with creating static websites that I’ve had in my career.
While Hugo is an awesome tool for building a static website, it still won’t automatically upload your files to Amazon S3. That’s where a service like GitHub Actions comes into play.
GitHub Actions is a tool that helps automate tasks such as building, testing, and deploying code hosted on GitHub. In our case, we want to store our Hugo project in a GitHub code repository and use GitHub Actions to trigger a workflow that will automatically keep our static website up to date whenever we make any changes. That way, we can focus on managing our website’s content without the hassles of uploading files to our hosting platform.
In this article, we’ll show how you can use GitHub Actions to automatically generate your Hugo-backed static website and upload it to an Amazon S3 bucket securely whenever there’s a change in your repository.
Allowing GitHub Actions to access Amazon S3
At a high level, using GitHub Actions to automatically update an Amazon S3-based static website from a GitHub code repository involves two steps: Building the files for the website using Hugo and uploading the generated files to an Amazon S3 bucket. GitHub Actions can do both for us once we set up a workflow.
Before getting started with the site generation, we need some initial setup to give GitHub Actions the correct permissions to access Amazon S3. The remainder of this article focuses on setting up these permissions, and a follow-up article will show you how GitHub Actions uses these permissions.
Typically, developers and system administrators will create an IAM user in their AWS account and set up the account’s access keys on GitHub since it’s the easiest route. For this article, we won’t use this method of authentication but instead use OpenID Connect, which is a preferred way to securely allow external entities access to AWS resources.
OpenID Connect (or OIDC) allows users to authenticate via an identity provider to another service. In this case, our identity provider will be GitHub. The OpenID Connect service will request a temporary access token directly from the provider—AWS, in our example. If configured correctly, the provider grants access to the request. While slightly more complex than configuring an IAM user’s access key, this method allows a more secure and manageable way to enable GitHub and AWS to communicate with each other.
Configuring AWS to grant access to GitHub’s OpenID Connect provider takes a couple of different steps that can be tedious to set up since it requires creating various resources on AWS. Instead of handling this process manually, let’s leverage the power of Terraform to quickly set up and manage what we need on an AWS account.
Setting up GitHub OIDC and IAM using Terraform
Configuring AWS to grant access to a GitHub Actions workflow requires three new IAM resources:
- First, you must create an IAM identity provider to let AWS know about GitHub’s OpenID Connect identity provider in your account.
- Next, you’ll need to create an IAM policy that specifies the permissions you want to grant access to in GitHub’s authentication requests.
- Finally, you’ll need to create an IAM role associated with the identity provider and which has the IAM policy attached to it.
These three resources work together to give you access to your AWS account and perform any necessary actions from GitHub Actions. When running a GitHub Actions workflow, it will assume the specified IAM role upon being granted authentication from AWS via the identity provider, allowing you to perform any API calls to the specified IAM permissions defined in the policy.
Let’s see how we can create each of these policies using Terraform to make the process consistent, reproducible, and simpler to manage. We’ll create a single file called main.tf
to store the configuration for creating all of the AWS resources necessary to use our account in a GitHub Actions workflow.
Setting up the AWS provider in Terraform
Since we’ll use Terraform to manage our AWS resources, we first need to define the AWS provider in our Terraform script. At the top of the main.tf
file, let’s define the required providers and the version in the terraform
block:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "4.59.0"
}
}
}
In this configuration, we’re not specifying which AWS account we want to use to create the resources defined in the rest of the Terraform script. The Terraform AWS provider lets you configure your credentials in various ways, so you’ll need to choose your preferred method of authentication. For this article, we’ll assume that we have our credentials and our desired AWS region configuration set up in the system applying the changes, so we won’t need to specify them in this block.
Configuring the identity provider
After setting up our initial Terraform configuration, we can begin creating AWS resources on the account. The first step we need for GitHub Actions is creating a new OpenID Connect identity provider in the AWS account. Terraform’s AWS provider includes the aws_iam_openid_connect_provider
resource to help you create and manage identity providers. This resource requires three parameters:
- The URL of the identity provider we’ll use to allow AWS to authenticate a GitHub account (the
url
parameter). - The client list or audience that identifies which external-facing clients can accept the OpenID Connect token (the
client_id_list
parameter). - The thumbprint of the identity provider’s server certificates (the
thumbprint_list
parameter).
Here’s how to add the GitHub OpenID Connect provider as an IAM identity provider using Terraform, which you can append to the main.tf
file after the terraform
block:
resource "aws_iam_openid_connect_provider" "github_oidc" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1"
]
}
GitHub’s documentation on configuring OpenID Connect with AWS contains most of this information. Obtaining the thumbprint is a bit tricky since the documentation does not publish it. For simplicity, the provided value in the example above is the current thumbprint and doesn’t need to change unless GitHub changes the identity provider’s certificates, which shouldn’t occur often.
To use the newly-created identity provider on AWS, we need to create an IAM role. The role will work similarly to an IAM user but without needing to generate its own set of credentials. The identity provider uses this role to request a temporary token that allows access to AWS. Before creating the role, we’ll need to create an IAM policy that sets up the required permissions for our automated workflow on GitHub Actions.
Creating an IAM policy to allow access to an Amazon S3 bucket
Another resource included by Terraform’s AWS provider is the aws_iam_policy
resource. This resource lets us create an IAM policy to define the permissions a user or role needs in AWS and specify what actions to allow or deny.
Before creating a new IAM policy, we’ll need to know which permissions are required by the role we’ll use on GitHub Actions. While providing a loose set of permissions is easier (like granting all available S3 permissions to the role), it’s generally a bad practice since it introduces massive security risks. For example, if the IAM user or role gets compromised, the bad actor will obtain more extensive access to your AWS account. Instead, it’s best to follow the principle of least privilege, where you grant the absolute minimum permissions to the user or role—nothing more, nothing less. That way, if the user or role does get compromised, you’ll limit the potential damage to your AWS account.
In the GitHub Actions workflow, we’ll use the sync
command to copy the files generated by Hugo to our Amazon S3 bucket. Using this command requires three sets of permissions to define in the policy:
- List the bucket contents to verify existing files that need updating (
s3:ListBucket
). - Put objects inside the bucket to copy new files from GitHub Actions (
s3:PutObject
). - Delete objects from the bucket that don’t exist anymore (
s3:DeleteObject
).
The aws_iam_policy
resource in Terraform only requires a single argument to define the policy (the policy
parameter). We’ll also use a few optional parameters to specify the policy name and description to make it easier to manage in the future. AWS policies are defined as a JSON document, so we’ll use Terraform’s built-in jsonencode
function to convert a map into an appropriate JSON string.
Add the following Terraform resource
block in the main.tf
file to create an IAM policy based on the permissions defined above, scoped to the Amazon S3 bucket that’s hosting the static website (dennis-static-site
, in this example):
resource "aws_iam_policy" "github_actions_policy" {
name = "S3GitHubActionsPolicy"
description = "Policy to allow GitHub Actions to put objects in the dennis-static-site S3 bucket."
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Action" : [
"s3:ListBucket"
],
"Effect" : "Allow",
"Resource" : [
"arn:aws:s3:::dennis-static-site"
]
},
{
"Action" : [
"s3:PutObject",
"s3:DeleteObject"
],
"Effect" : "Allow",
"Resource" : [
"arn:aws:s3:::dennis-static-site/*"
]
}
]
})
}
The policy allows two separate actions, as defined by the Action
key in the map. The first action we’re allowing is to list the bucket’s contents, and the second action allows putting and deleting objects inside the bucket. Since the ARN defined in the Resource
differs for both actions, we must define these actions separately. This policy will only allow our role to perform these specific actions and, by default, block all other actions.
Creating an IAM role with an attached policy associated to an identity provider
With the identity provider and policy in place, we can set up an IAM role to use in our GitHub Actions workflow. As expected, Terraform’s AWS provider has the aws_iam_role
resource that lets us create an IAM role with a policy to specify which entity has permission to assume it.
Since we’re talking about policies in relation to IAM roles, one thing to note is that the policy we assign to the role is not the same type as the IAM policy we created earlier. The permissions system in AWS is super-useful, but it’s also confusing at times and possibly the area where most developers and DevOps pros get tripped up. While both types of policies grant permissions to AWS resources, their difference lies in their scope and management.
At a high level, the main differences between each type of policy are:
- An IAM policy can be attached to different IAM entities (users, roles, etc.) as a managed policy and can be modified separately from their attached entities.
- An IAM role policy is only associated with specific roles and can only be modified as part of the role’s configuration.
These policies have other differences, such as the way they inherit permissions. Depending on your use case, you can use one over the other. In many cases, you’ll combine both types of policies for your resources, which we need for our example in this article.
The aws_iam_role
resource requires one parameter—the policy used to grant permissions to another entity (the assume_role_policy
parameter). We want the GitHub OpenID Connect identity provider to assume this role for GitHub Actions. We’ll also want to use a few optional parameters to give the role a descriptive name and attach the IAM policy to provide it with the correct permissions to access and update our Amazon S3 bucket.
First, let’s add the following resource
block for creating the IAM role in main.tf
, and we’ll dig into each of the different parameters and how they work.
resource "aws_iam_role" "github_actions_role" {
name = "S3GitHubActionsRole"
description = "Role to use for GitHub Actions."
managed_policy_arns = [aws_iam_policy.github_actions_policy.arn]
assume_role_policy = jsonencode(
{
"Version" : "2012-10-17",
"Statement" : [
{
"Effect" : "Allow",
"Principal" : {
"Federated" : aws_iam_openid_connect_provider.github_oidc.arn
},
"Action" : "sts:AssumeRoleWithWebIdentity",
"Condition" : {
"StringEquals" : {
"token.actions.githubusercontent.com:aud" : aws_iam_openid_connect_provider.github_oidc.client_id_list[0]
},
"StringLike" : {
"token.actions.githubusercontent.com:sub" : "repo:dennmart/hugo_github_actions_s3:*"
}
}
}
]
}
)
}
The name
and description
parameters of this block are self-explanatory. They’re optional parameters for the aws_iam_role
resource. Terraform will auto-generate a role name and leave the description blank if left undefined. The more juicy portions of this resource block are the managed_policy_arns
and the assume_role_policy
parameters.
First, the managed_policy_arns
parameter is an optional list of ARNs allowing us to attach existing IAM policies to the role that we’re creating in this resource block. As explained earlier, you can attach IAM policies to various IAM entities as a managed policy, granting the entity its permissions. For our scenario, we want to attach the previously-created IAM policy—the policy that gives read, write, and delete access to our desired Amazon S3 bucket—to the new role. Since we created the policy in this Terraform configuration, we can reference the ARN of the created policy directly in the list.
The following parameter, assume_role_policy
, is a required string to define the permissions that an entity needs to assume the role. In this example, the entity that will assume the role is the GitHub OpenID Connect identity provider we set up earlier in this article. Like an IAM policy, the role policy is also defined as a JSON document, so we’re using Terraform’s built-in jsonencode
function again. The policy here handles a lot of the underlying work that AWS uses to give the identity provider what it needs to use the role. Let’s break down the key components to understand what each element of the policy statement does.
The "Effect"
element lets us allow access to this resource. By default, AWS policies deny access to resources unless specified using this element, so we must define it in our policy so the role can access our AWS account.
The "Principal"
element describes which entity can access the role. Since we’re using an identity provider on AWS, we’re specifying a federated session using the "Federated"
key. The value of the key will be the ARN of the identity provider. As we did earlier, we can reference the ARN of the identity provider we created in this Terraform configuration.
Next, the "Action"
element tells AWS which actions we’re allowing to the principal defined in this role policy. The sts:AssumeRoleWithWebIdentity
action lets the role retrieve temporary credentials, given that they’ve already authenticated with an identity provider. The idea here is that the role will get authenticated through GitHub OpenID Connect, so this policy will allow GitHub Actions to get the tokens it needs to access a user’s AWS resources.
Finally, the "Condition"
element lets us add conditions for using this created role. These conditions are for security purposes since they’ll determine which requests are allowed to assume this role. Without these conditions, untrusted requests can fetch tokens using the role. While the "Condition"
element is optional in the AWS policy, it’s a good practice to establish for your organization, as you’ll want to lock down which requests can assume the role.
We’ll restrict the role’s action only if the client request comes from GitHub OpenID Connect. We can add this check by verifying the OIDC token that GitHub sends. If the aud
claim in the request token matches the audience we defined when creating the identity provider (which we reference here), the condition will pass. Since we watch an exact match, we’re defining this condition using StringEquals
.
We also want to restrict the action only if GitHub Actions makes this request from our repository under specific contexts. The sub
claim coming from the request token contains this information, depending on how GitHub Actions triggers this request. In this example, we want to restrict access when the request is made under the https://github.com/dennmart/hugo_github_actions_s3/
GitHub repo. We also want it to allow access from any event and environment in the repository using the wildcard character (*
), so we’re defining this condition using StringLike
.
If you want to restrict this policy even further, like only granting access to the role from a specific branch or tag, GitHub’s documentation contains multiple examples on how it assembles the value of the sub
claim. For now, these conditions are enough to keep any other identity providers or GitHub repositories from assuming this role.
Before wrapping up our Terraform configuration, we’ll want to grab the ARN of our managed IAM role to use when setting up our GitHub Actions workflow. After creating the role, we can fetch this information from the AWS console, but it’s much easier to grab as part of our infrastructure management. Add the following output
block at the end of the main.tf
file, which will display the ARN when we apply our changes with Terraform:
output "iam_role_arn" {
value = aws_iam_role.github_actions_role.arn
}
Putting it all together
We’re now ready to put all these pieces together. Here’s what our main.tf
file looks like after all this configuration:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "4.59.0"
}
}
}
resource "aws_iam_openid_connect_provider" "github_oidc" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1"
]
}
resource "aws_iam_policy" "github_actions_policy" {
name = "S3GitHubActionsPolicy"
description = "Policy to allow GitHub Actions to put objects in the dennis-static-site S3 bucket."
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Action" : [
"s3:ListBucket"
],
"Effect" : "Allow",
"Resource" : [
"arn:aws:s3:::dennis-static-site"
]
},
{
"Action" : [
"s3:PutObject",
"s3:DeleteObject"
],
"Effect" : "Allow",
"Resource" : [
"arn:aws:s3:::dennis-static-site/*"
]
}
]
})
}
resource "aws_iam_role" "github_actions_role" {
name = "S3GitHubActionsRole"
description = "Role to use for GitHub Actions."
managed_policy_arns = [aws_iam_policy.github_actions_policy.arn]
assume_role_policy = jsonencode(
{
"Version" : "2012-10-17",
"Statement" : [
{
"Effect" : "Allow",
"Principal" : {
"Federated" : aws_iam_openid_connect_provider.github_oidc.arn
},
"Action" : "sts:AssumeRoleWithWebIdentity",
"Condition" : {
"StringEquals" : {
"token.actions.githubusercontent.com:aud" : aws_iam_openid_connect_provider.github_oidc.client_id_list[0]
},
"StringLike" : {
"token.actions.githubusercontent.com:sub" : "repo:dennmart/hugo_github_actions_s3:*"
}
}
}
]
}
)
}
output "iam_role_arn" {
value = aws_iam_role.github_actions_role.arn
}
With our resources codified, we can use Terraform to apply them to our AWS account. Make sure you set up Terraform to use your AWS credentials, then run terraform apply
to review and apply your changes immediately:
❯ terraform apply
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
+ create
Terraform will perform the following actions:
# aws_iam_openid_connect_provider.github_oidc will be created
+ resource "aws_iam_openid_connect_provider" "github_oidc" {
+ arn = (known after apply)
+ client_id_list = [
+ "sts.amazonaws.com",
]
+ id = (known after apply)
+ tags_all = (known after apply)
+ thumbprint_list = [
+ "6938fd4d98bab03faadb97b34396831e3780aea1",
]
+ url = "https://token.actions.githubusercontent.com"
}
# aws_iam_policy.github_actions_policy will be created
+ resource "aws_iam_policy" "github_actions_policy" {
+ arn = (known after apply)
+ description = "Policy to allow GitHub Actions to put objects in the dennis-static-site S3 bucket."
+ id = (known after apply)
+ name = "S3GitHubActionsPolicy"
+ path = "/"
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = [
+ "s3:ListBucket",
]
+ Effect = "Allow"
+ Resource = [
+ "arn:aws:s3:::dennis-static-site",
]
},
+ {
+ Action = [
+ "s3:PutObject",
+ "s3:DeleteObject",
]
+ Effect = "Allow"
+ Resource = [
+ "arn:aws:s3:::dennis-static-site/*",
]
},
]
+ Version = "2012-10-17"
}
)
+ policy_id = (known after apply)
+ tags_all = (known after apply)
}
# aws_iam_role.github_actions_role will be created
+ resource "aws_iam_role" "github_actions_role" {
+ arn = (known after apply)
+ assume_role_policy = (known after apply)
+ create_date = (known after apply)
+ description = "Role to use for GitHub Actions."
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "S3GitHubActionsRole"
+ name_prefix = (known after apply)
+ path = "/"
+ tags_all = (known after apply)
+ unique_id = (known after apply)
}
Plan: 3 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ iam_role_arn = (known after apply)
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_iam_policy.github_actions_policy: Creating...
aws_iam_openid_connect_provider.github_oidc: Creating...
aws_iam_openid_connect_provider.github_oidc: Creation complete after 1s [id=arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com]
aws_iam_policy.github_actions_policy: Creation complete after 2s [id=arn:aws:iam::111111111111:policy/S3GitHubActionsPolicy]
aws_iam_role.github_actions_role: Creating...
aws_iam_role.github_actions_role: Creation complete after 1s [id=S3GitHubActionsRole]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
iam_role_arn = "arn:aws:iam::111111111111:role/S3GitHubActionsRole"
If all goes well, the IAM identity provider, policy, and role will get created in a matter of seconds. You can log in to your AWS console to ensure Terraform sets everything up as expected.
That’s quite a bit of configuration to put together, and it might seem like a bit of overkill since this can all be done through the AWS console or command line tool in a few minutes if you know what you’re doing. However, setting up tasks like these using an Infrastructure as Code tool like Terraform contains tons of benefits:
- You’ll have everything documented so the next time you need to configure a similar identity provider or a new employee joins the team, everyone will know how these resources work.
- You have the ability to easily add or remove permissions in seconds by updating the IAM policy in the Terraform script and running
terraform apply
. - It will encourage the rest of your team to reuse parts of this configuration to create new IAM roles for other GitHub repositories or actions.
Now that we have the permissions needed to update our static website in Amazon S3 straight from GitHub Actions, we can set up the workflow to make that happen automatically when we update the code repository. We’ll cover that in the following article, so stay tuned.
Are you looking for a DevOps expert to help you with AWS, Terraform, or other DevOps services? With 20 years of experience at startups and an AWS Certified DevOps Engineer, I can guide you through the complexities of modern infrastructure to get the most out of your investment. I’d love to help you build and maintain reliable, scalable, and secure systems, from infrastructure management and deployment to cloud architecture and monitoring. Contact me today to learn more about my services.