• Home
  • About
  • Résumé
  • RunLog
  • Posts
    • All Posts
    • All Tags

Preview page deployment with Github-Actions and Cloudfront

08 Mar 2023

Reading time ~3 minutes

Like Netlify, Vercel, and other tools, we built our own using Github-Actions, AWS Cloudfront, APIGateway, and S3 bucket.

Our goal is when a pull-request is open, we build the artifact and host it on <feature-branch-name>.preview.company.com. When the PR is merged or closed, the artifact folder in the S3 bucket will get removed.

User Flow

  • When frontend engineer opens a pull-request, an artifact is build and host on <feature-branch-name>.preview.company.com.
  • When the pull-request is closed or merged, remove the artifact from S3 bucket.

Infrastructure

  • Build a Cloudfront that is fronting the hosting bucket and serving on *.preview.company.com
    • This distribution needs to rewrit the request URI from <feature-branch-name>.preview.company.com into <feature-branch-name>.preview.company.com/preview/<feature-branch-name>. This step tries to turn the subdomain into a unique path under S3.
    • This distribution requires wildcard SSL certificate and wildcard route53 record.
    • This distribution will also host the API gateway under the *.preview.company.com in order to avoid excessive CORS requests.
  • Build Github workflows
    • Upon pull-request update, build the frontend app and place under /preview/<feature-branch-name>. DNS name will only contain alphanumberic and -.
    • Upon pull-request merge/close, remove everything under the /preview/<feature-branch-name> folder in S3.

S3 Bucket

First, we need to have a S3 bucket to host our deployment. Because our frontend app is already hosting in a S3 bucket, we simply just place the feature branch deployments under that bucket.

Feature branch will be placed under /feature/<feature-branch-name>.

$ aws s3 ls s3://frontend-hosting-bucket-dev01/preview/
                           PRE fe-ui-add-coverage-reports/
                           PRE fe-ui-add-coverage-reports/
                           PRE fe-ui-fix-typing/
                           PRE fe-ui-feat-communications-settings-adjustments/
2023-03-07 14:59:38          0

API Gateway

Our API Gateway has a custom domain name, certificate is created via Terraform as well.

$ aws apigateway get-domain-name --domain-name "*.preview.company.com"
{
    "domainName": "*.preview.company.com",
    "regionalDomainName": "d-abc123mlpl.execute-api.us-east-2.amazonaws.com",
    "regionalHostedZoneId": "ABCDEF49E0EPZ",
    "regionalCertificateArn": "arn:aws:acm:us-east-2:1234567890:certificate/567890-0987-1234-9753-20d008a7801a",
    "endpointConfiguration": {
        "types": [
            "REGIONAL"
        ]
    },
    "domainNameStatus": "AVAILABLE",
    "securityPolicy": "TLS_1_2",
    "tags": {
        "infra": "terraform",
    }
}

Certificate.

$ aws acm list-certificates | jq -r '.CertificateSummaryList[] | select(.DomainName == "company.com") | .CertificateArn, .DomainName, .SubjectAlternativeNameSummaries'
arn:aws:acm:us-east-2:1234567890:certificate/567890-0987-1234-9753-20d008a7801a
company.com
[
  "company.com",
  "*.company.com",
  "*.preview.company.com",
  "*.company.com",
  "preview.company.com",
  "company.com"
]

Cloudfront

Our cloudfront has two origins, one for frontend (S3 bucket), one for backend (APIGateway).

$ aws cloudfront get-distribution --id EFGHIJK5QKPL
{
    "Distribution": {
        "ARN": "arn:aws:cloudfront::1234567890:distribution/EFGHIJK5QKPL",
        "DomainName": "defghijkl7iazk.cloudfront.net",
        "DistributionConfig": {
            "Aliases": {
                "Items": [
                    "*.preview.company.com",
                    "preview.company.com"
                ]
            },
            "DefaultRootObject": "index.html",
            "Origins": {
                "Quantity": 2,
                "Items": [
                    {
                        "Id": "backend-api-gateway",
                        "DomainName": "d-abc123mlpl.execute-api.us-east-2.amazonaws.com",
                    },
                    {
                        "Id": "frontend-hosting-bucket",
                        "DomainName": "frontend-hosting-bucket.s3.us-east-2.amazonaws.com",
                        "S3OriginConfig": {
                            "OriginAccessIdentity": "origin-access-identity/cloudfront/EFGHIJKL4VY0BV"
                        },
                    }
                ]
            },
            "DefaultCacheBehavior": {
                "TargetOriginId": "frontend-hosting-bucket",
                "FunctionAssociations": {
                    "Items": [
                        {
                            "FunctionARN": "arn:aws:cloudfront::1234567890:function/frontend-preview-dev01-rewrite-uri-cloudfront-function",
                            "EventType": "viewer-request"
                        }
                    ]
                },
            },
            "CacheBehaviors": {
                "Items": [
                    {
                        "PathPattern": "/api/*",
                        "TargetOriginId": "backend-api-gateway",
                    }
                ]
            },
            "Comment": "Managed by Terraform",
            "Enabled": true,
            "ViewerCertificate": {
                "Certificate": "arn:aws:acm:us-east-1:1234567890:certificate/befg1234-ab12-0987-1234-08fa3ab1394c",
                "CertificateSource": "acm"
            },
        },
        "AliasICPRecordals": [
            {
                "CNAME": "*.preview.company.com",
                "ICPRecordalStatus": "APPROVED"
            },
            {
                "CNAME": "preview.company.com",
                "ICPRecordalStatus": "APPROVED"
            }
        ]
    }
}

Certificate

$ aws acm list-certificates --region us-east-1 | jq -r '.CertificateSummaryList[] | select(.DomainName == "preview.company.com") | .CertificateArn, .DomainName, .SubjectAlternativeNameSummaries'
arn:aws:acm:us-east-1:1234567890:certificate/befg1234-ab12-0987-1234-08fa3ab1394c
preview.company.com
[
  "preview.company.com",
  "*.preview.company.com"
]

For default behavior, we set the Viewer request to a cloudfront function.

$ aws cloudfront get-function --name frontend-preview-dev01-rewrite-uri-cloudfront-function /tmp/function.js

$ cat /tmp/function.js
function handler(event) {
    // rewrite request against <branch>.preview.domain.com/uri to <branch>.preview.domain.com/preview/<branch>/uri
    var request = event.request;
    var host = request.headers.host.value;
    var subdomains = host.split('.');

    if (subdomains[1] === 'preview') {
        request.uri = '/preview/' + subdomains[0] + request.uri;
    }

    return request;
}

Github-Actions

name: build-preview-artifact

on:
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
      - closed
    branches:
      - main
      - development

env:
  BUCKET: frontend-hosting-bucket

permissions:
  id-token: write
  contents: read
  actions: read
  pull-requests: write

jobs:
  build-static-objects:
    runs-on: ubuntu-20.04
    environment:
      name: Preview
      url: https://$.preview.company.com

    steps:
      - id: checkout-code
        uses: actions/checkout@v3
        with:
          ref: $

      - id: configure-credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: arn:aws:iam::$:role/github-actions-cd-frontend
          aws-region: us-east-2

      - id: sub-domain-name
        run: |
          echo "subdomain=$(echo $-$ | tr '/._' '-' | tr '[:upper:]' '[:lower:]' | tr -dc '[a-zA-Z0-9-]\n\r')" >> $GITHUB_ENV

      - id: config-nodejs
        if: github.event.action != 'closed'
        uses: actions/setup-node@v3
        with:
          node-version: 18.1.0
          cache: "npm"

      - id: build-react-app
        if: github.event.action != 'closed'
        env:
          SENTRY_AUTH_TOKEN: $
          ...
        run: |
          npm ci
          npm run build

      - id: deploy-artifact
        if: github.event.action != 'closed'
        run: |
          aws s3 cp build/ s3://$BUCKET/preview/$ --cache-control "public, max-age=0, must-revalidate" --exclude 'static/*' --recursive

      - id: clean-up-s3
        if: github.event.action == 'closed'
        run: |
          aws s3 rm s3://$BUCKET/preview/$ --recursive


technologydocdevopsawsterraformgithub Share Tweet +1