Deploy Hugo Sites With Terraform and GitHub Actions (Part 1)

Manage access to your AWS account via GitHub's OpenID Connect provider by leveraging Terraform, simplifying and streamlining your workflow.

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.

More articles you might enjoy

Article cover for Deploy Hugo Sites With Terraform and GitHub Actions (Part 2)
DevOps
Deploy Hugo Sites With Terraform and GitHub Actions (Part 2)

Automate your Hugo static website updates to an Amazon S3 bucket with GitHub Actions to always have your site up to date.

Article cover for Deploying Your Serverless Applications Easily With Terraform
Serverless
Deploying Your Serverless Applications Easily With Terraform

Discover how Terraform can help you simplify and manage your serverless applications and infrastructure with minimal effort.

Article cover for Create an S3 Bucket for Website Hosting With Terraform
Terraform
Create an S3 Bucket for Website Hosting With Terraform

Discover how to effortlessly host your static website for cheap on Amazon S3 using Terraform with this step-by-step guide.

About the author

Hi, my name is Dennis! As a freelancer and consultant, I work with tech organizations worldwide to help them build effective, high-quality software. It's my mission to help these companies get their idea off the ground quickly and in the right way for the long haul.

For over 20 years, I've worked with startups and other tech companies across the globe to help them successfully build effective, high-quality software. My experience comes from working with early-stage companies in New York City, San Francisco, Tokyo, and remotely with dozens of organizations around the world.

My main areas of focus are full-stack web development, test automation, and DevOps. I love sharing my thoughts and expertise around test automation on my blog, Dev Tester, and have written a book on the same topic.

Dennis Martinez - Photo
Learn more about my work Schedule a call with me today