Thoughts on the web, travel & remote working life by Paddy O’Hanlon

How to Deploy a Secure Static Website: S3, CloudFront, HTTPS, and GitHub Actions

Written from

These are notes for my own reference, not a comprehensive post, I’ll leave out most explanations and won’t explore various options. This is just a set of steps that worked for my needs, hopefully they’re useful to someone else. Getting this to work today took far too long!

I was deploying a Vue app to a subdomain, but the steps should be the same or similar for any static site.

Create and configure an S3 bucket

  • Create an S3 bucket in us-east-1 (CloudFront requires us-east-1) with the domain name to be hosted (e.g. cloud.example.com). Uncheck ‘Block all public access’
  • After creating, go to the Properties tab, scroll to bottom and enable static website hosting, set Index document to index.html. Save.
  • For a quick sanity check, upload a test index.html file.
  • Go to the Permissions tab. Add the following policy. Then you could test the S3 static website URL (go to the Properties tab again, scroll to bottom to find the URL)
{
    "Version": "2012-10-17",
    "Id": "Policy1729027101551",
    "Statement": [
        {
            "Sid": "Stmt1729027092485",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cloud.example.com/*"
        }
    ]
}

Create an ACM cert and verify by adding CNAME records

  • Create a cert for *.example.com in ACM
  • Add a CNAME in Route 53. CNAME Name in ACM maps to the Subdomain CNAME value in Route 53.
  • The cert will probably verify in a few minutes.

Note, to add staging subdomain (e.g. cloud.staging.example.com), it will needed to be included on the cert (*.example.com doesn’t cover it), or a separate cert needs to be created. *.staging.example.com works.

Create a CloudFront distribution

  • For Origin domain, choose the Amazon S3 bucket, then accept prompt to use the website URL.
  • Choose Redirect HTTP to HTTPS
  • For WAF, select Do not enable security protections
  • For Alternate domain name (CNAME) add the domain (e.g. cloud.example.com)
  • Choose the cert just created
  • Set Default root object to index.html
  • With Vue, and SPAs generally, when you refresh a page or directly access a route other than the root, the server looks for a file that doesn’t exist (as your app’s routing is handled client-side). To solve this, add a redirect rule. On the Error pages tab, add a 404 error response with Response page path to /index.html status code 200.

Configure the subdomain in Route 53

Assuming the root domain is already added as a hosted zone:

  • Create an A record in Route 53 in the hosted zone (e.g. example.com)
  • When creating toggle Alias on
  • Route traffic to Alias to CloudFront distribution, us-east-1 (US East N. Virginia)
  • Select the CloudFront dist.

Create an IAM user for the deploy GitHub Action

  • Create a user in IAM, named something like
    github-actions-s3-deployer
  • Add the following policy, exclude staging values if not required, of course:
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "AllowS3BucketManipulation",
			"Effect": "Allow",
			"Action": [
				"s3:PutObject",
				"s3:GetObject",
				"s3:DeleteObject",
				"s3:ListMultipartUploadParts",
				"s3:AbortMultipartUpload",
				"s3:ListBucket"
			],
			"Resource": [
				"arn:aws:s3:::cloud.example.com/*",
				"arn:aws:s3:::staging.cloud.example.com/*"
			]
		},
		{
			"Sid": "AllowS3BucketListing",
			"Effect": "Allow",
			"Action": [
				"s3:ListBucket"
			],
			"Resource": [
				"arn:aws:s3:::cloud.example.com",
				"arn:aws:s3:::staging.cloud.example.com"
			]
		},
		{
			"Sid": "CFInvalidation",
			"Effect": "Allow",
			"Action": "cloudfront:CreateInvalidation",
			"Resource": [
				"arn:aws:cloudfront::147997118411:distribution/<cloudfront_dist_id>",
				"arn:aws:cloudfront::147997118411:distribution/<staging_cloudfront_dist_id>"
			]
		}
	]
}

Add GitHub Action to deploy to S3 and Invalidate the CloudFront cache

Create .github/workflows/aws-s3-cloudfront-deploy.yml

Add the following:

name: Deploy to S3 and invalidate CloudFront cache
on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        description: 'Where to deploy'
        required: true
        options:
          - prod
          - staging

jobs:
  run:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment }}
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    steps:
      - uses: actions/checkout@v3

      - name: Install dependencies
        run: npm ci

      - name: Build for production
        if: github.event.inputs.environment == 'prod'
        env:
          VITE_APP_ID: ${{ secrets.VITE_APP_ID }}
        run: npm run build

      - name: Build for staging
        if: github.event.inputs.environment == 'staging'
        env:
          VITE_APP_ID: ${{ secrets.STAGING_VITE_APP_ID }}
          VITE_BAZAAR_URI: ${{ secrets.STAGING_VITE_BAZAAR_URI }}
        run: npm run build

      - name: Deploy to S3 and invalidate CloudFront - Production
        if: github.event.inputs.environment == 'prod'
        uses: reggionick/s3-deploy@v4
        with:
          folder: dist
          bucket: ${{ secrets.S3_BUCKET }}
          bucket-region: ${{ secrets.S3_BUCKET_REGION }}
          dist-id: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
          invalidation: /
          delete-removed: true
          no-cache: true
          private: true
          files-to-include: '{.*/**,**}'

      - name: Deploy to S3 and invalidate CloudFront - Staging
        if: github.event.inputs.environment == 'staging'
        uses: reggionick/s3-deploy@v4
        with:
          folder: dist
          bucket: ${{ secrets.STAGING_S3_BUCKET }}
          bucket-region: ${{ secrets.S3_BUCKET_REGION }}
          dist-id: ${{ secrets.STAGING_CLOUDFRONT_DISTRIBUTION_ID }}
          invalidation: /
          delete-removed: true
          no-cache: true
          private: true
          files-to-include: '{.*/**,**}'

Uses the S3 Deploy Marketplace action.

VITE_APP_ID and STAGING_VITE_BAZAAR_URI are env vars my project requires during the build step. dist is my build directory.

Create the GitHub Action secrets in the yaml file.

If staging is needed, repeat the steps in this post and create the same resources for staging.

One day I’ll create a CloudFormation template for this… Maybe.