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.
- This distribution needs to rewrit the request URI from
- 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.
- Upon pull-request update, build the frontend app and place under
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