So you’ve built a website, and you want to release it to the world? There are many unique ways this can be done. One of them is using AWS S3. It is simple, cheap, and convenient if you already use AWS. In this post, I will explain how to configure an S3 bucket to host a website, how to create the corresponding AWS resources using Terraform, and last but not least, how to automate the process so that whenever you push changes to your GitHub repository, they will be released automatically.

Inception

I would assume that you already have a Terraform/AWS project where you store your infrastructure code as well as a GitHub repository with your web project built using NPM.

First, I will create an S3 bucket. I will allow everyone to read its content (because it will host my HTML, JS, and CSS files). I will add a website configuration so that AWS will know where to direct people navigating the website.

In your Terraform project, create a website.tf file with the following content:

resource "aws_s3_bucket" "webiste_s3" {
  bucket = "my-mega-cool-app" # <--- replace this with your unique bucket name
}

resource "aws_s3_bucket_policy" "webisite_read_write_policy" {
  bucket = aws_s3_bucket.webiste_s3.bucket
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "PublicReadGetObject",
        "Effect" : "Allow",
        "Principal" : "*",
        "Action" : "s3:GetObject",
        "Resource" : "arn:aws:s3:::${aws_s3_bucket.webiste_s3.bucket}/*"
      }
    ]
  })
  depends_on = [
    aws_s3_bucket.webiste_s3,
    aws_s3_bucket_acl.webiste_s3_acl,
    aws_s3_bucket_website_configuration.webiste_s3_configuration
  ]
}

resource "aws_s3_bucket_acl" "webiste_s3_acl" {
  bucket     = aws_s3_bucket.webiste_s3.id
  acl        = "public-read"
  depends_on = [aws_s3_bucket_ownership_controls.webiste_s3_bucket_acl_ownership]
}

resource "aws_s3_bucket_website_configuration" "webiste_s3_configuration" {
  bucket = aws_s3_bucket.webiste_s3.id
  index_document {
    suffix = "index.html"
  }
  error_document {
    key = "index.html"
  }
}

resource "aws_s3_bucket_ownership_controls" "webiste_s3_bucket_acl_ownership" {
  bucket = aws_s3_bucket.webiste_s3.id
  rule {
    object_ownership = "BucketOwnerPreferred"
  }
  depends_on = [aws_s3_bucket_public_access_block.webiste_s3_bucket_public_access_block]
}

resource "aws_s3_bucket_public_access_block" "webiste_s3_bucket_public_access_block" {
  bucket                  = aws_s3_bucket.webiste_s3.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

Make sure to replace the bucket name with your own unique bucket name. It must be unique within the AWS region.

Now execute terraform apply. This should create six new resources in AWS necessary for storing your website.

If you open the AWS console and navigate to your newly created bucket, you can find its public URL, which you can use to access your website. Go to the “Properties” tab of your bucket, scroll down, and you will see the URL.

Website URL in an S3 bucket

You will see a 404 error if you open it because the bucket is empty. You can manually upload an index.html file, and it will start working. However, the purpose of this post is to automate this process.

What I want to do is upload the prebuilt website every time its source code changes in a Git repository hosted on GitHub.

Let’s assume you have a React app that is built with NPM. I will create a GitHub Actions workflow that will build it and upload it to the bucket once it’s built. For this to work, GitHub Actions need to have access to AWS. Correspondingly, GitHub Actions would need access key and secret of an AWS user.

A good practice is to create a dedicated user that only has access to this specific S3 bucket. I will do it in Terraform as well. Create a github_actions_user.tf file with the following content:

resource "aws_iam_user" "github_actions_user" {
  name = "github-actions-user"
  tags = {
    "description" : "A user for GitHub Actions"
  }
}

resource "aws_iam_access_key" "github_actions_user_key" {
  user = aws_iam_user.github_actions_user.name
}

resource "aws_iam_policy" "github_actions_s3_upload_policy" {
  name        = "github-actions-s3-upload-policy"
  description = "Policy to allow S3 upload"

  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:ListBucket",
        ],
        Effect = "Allow",
        Resource = [
          "arn:aws:s3:::${aws_s3_bucket.webiste_s3.bucket}",
          "arn:aws:s3:::${aws_s3_bucket.webiste_s3.bucket}/*",
        ],
      },
    ],
  })

  tags = {
    "description" : "Policy for GitHub Actions user"
  }
}

resource "aws_iam_policy_attachment" "github_actions_user_policy_attachment" {
  name       = "github_actions_user_policy_attachment"
  policy_arn = aws_iam_policy.github_actions_s3_upload_policy.arn
  users      = [aws_iam_user.github_actions_user.name]
}

output "github_actions_access_key" {
  value = aws_iam_access_key.github_actions_user_key.id
}

output "github_actions_secret_key" {
  sensitive = true
  value     = aws_iam_access_key.github_actions_user_key.secret
}

After you run terraform apply, you will have a user in place that can push data to your S3. To retrieve the user’s access keys, execute:

terraform output -json

This will print all the output variables that you have. Save the values of github_actions_access_key and github_actions_secret_key for later.

There are no further Terraform changes needed. Now let’s move to a web app. If you have a repository with a web app, you can use it. If you don’t, you can create a simple web app using React and push it to a GitHub repository:

npx create-react-app my-app

Once you have a repository with a web application, you can configure GitHub Actions to deploy the app to the S3 bucket. But first, let’s add an Access Key & Secret to GitHub so that GitHub Actions will have access to the bucket. Open the “Settings” tab of your repository on GitHub, then navigate to “Secrets and variables,” and click on “New repository secret.” Add two secrets: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY with the values that you saved earlier.

GitHub Actions Secrets

Now, the only missing part is a GitHub Actions workflow script. Go ahead and create a folder named .github/workflows. Inside this folder, create a file named release.yml with the following content:

name: Releas
on:
  push:
    branches:
      - main # <--- if you use a different branch name,
             # replace it with your branch name
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm install
      - name: Build project
        run: npm run build
      - name: Push build artifacts to S3
        uses: actions/upload-artifact@v3
        with:
          name: build-artifacts
          path: build # <--- a deafult build folder, change it to 'out' or 
                      # 'dist' if your project config is different from mine
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v3
        with:
          name: build-artifacts
          path: build # <--- one more place to change 'build'
                      # to 'out' or 'dist' if needed
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: us-east-1 # <--- if you are using a different
                                # region, replace it here

      - name: Push build artifacts to S3
        run: aws s3 sync build s3://my-mega-cool-app # <---
        # replace with your bucket name as well as change 'build'
        # to 'out' or 'dist' depending on your project config

Make sure you replace your bucket name from my-mega-cool-app with the actual name you’re using. Additionally, if your web project is built to a directory with a different name than “build,” make sure to replace it in three places within the yaml file.

Once you’ve made these changes, commit the updates and push them to your main branch. Navigate to the “Actions” tab of the repo to check the build status. After it turns green, you can visit your newly published website.

A completed website deployed to an S3

Congratulations, you’ve reached the end of this blog post!

As you move forward, you might be interested in exploring the option of using your own domain name for your website. However, delving into that topic is a story for another day.

Thanks for reading, and happy coding!