Deploy a Web App with Pulumi and GitHub Actions

This article will demonstrate how to deploy a React app to AWS S3 and CloudFront with Pulumi, while also creating the necessary SSL certificate and DNS records required.

Create react app command: npx create-react-app esp32-pwa-app --template cra-template-pwa-typescript

File structure

In the root folder of the project, create an infra directory alongside the React project directory, as well as a .github folder with a workflows sub folder.

/
.github/workflows/
esp32-pwa-app/
infra/

After creating a pulumi project in the infra directory, replace the index.ts file with the following code.

This will create:

  • a private bucket to store the React build files
  • an SSL cert in ACM and DNS records in the Route53 hosted zone to validate it
  • a CloudFront distribution using Origin Access Identity to allow secure access to the S3 bucket
  • a DNS record for the domain name
  • an S3 bucket policy to allow OAI
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const DOMAIN = 'cjscloud.city';
const SUB = 'app';
const bucketV2 = new aws.s3.BucketV2("bucketV2", {
bucket: `${SUB}.${DOMAIN}`,
tags: {
Name: `${SUB}.${DOMAIN}`,
}
});
const bAcl = new aws.s3.BucketAclV2("bAcl", {
bucket: bucketV2.id,
acl: "private",
});
// ACM SSL Cert for CloudFront Distrbution
const appCert = new aws.acm.Certificate("cert", {
domainName: `${SUB}.${DOMAIN}`,
tags: {
Environment: pulumi.getStack(),
},
validationMethod: "DNS",
});
// Route53 records
const hostedZoneId = aws.route53.getZone(
{ name: DOMAIN },
{ async: true }
).then(zone => zone.zoneId);
const certRecord = new aws.route53.Record("cert", {
zoneId: hostedZoneId,
name: appCert.domainValidationOptions[0].resourceRecordName,
type: appCert.domainValidationOptions[0].resourceRecordType,
ttl: 300,
records: [appCert.domainValidationOptions[0].resourceRecordValue],
});
const certificateValidation = new aws.acm.CertificateValidation("certificateValidation", {
certificateArn: appCert.arn,
validationRecordFqdns: [certRecord.fqdn],
});
// CloudFront
const oai = new aws.cloudfront.OriginAccessIdentity("oai", {
comment: "OAI for App bucket and cloudfront distribution",
});
const s3OriginId = "AppS3Origin";const s3Distribution = new aws.cloudfront.Distribution("s3Distribution", {
origins: [{
domainName: bucketV2.bucketRegionalDomainName,
originId: s3OriginId,
s3OriginConfig: {
originAccessIdentity: oai.cloudfrontAccessIdentityPath,
}
}],
enabled: true,
isIpv6Enabled: true,
comment: "ESP32 Wifi Config PWA",
defaultRootObject: "index.html",
aliases: [`${SUB}.${DOMAIN}`,],
defaultCacheBehavior: {
allowedMethods: [
"GET",
"HEAD",
"OPTIONS",
],
cachedMethods: [
"GET",
"HEAD",
],
targetOriginId: s3OriginId,
forwardedValues: {
queryString: false,
cookies: {
forward: "none",
},
},
viewerProtocolPolicy: "allow-all",
minTtl: 0,
defaultTtl: 3600,
maxTtl: 86400,
},
orderedCacheBehaviors: [
{
pathPattern: "/*",
allowedMethods: [
"GET",
"HEAD",
"OPTIONS",
],
cachedMethods: [
"GET",
"HEAD",
],
targetOriginId: s3OriginId,
forwardedValues: {
queryString: false,
cookies: {
forward: "none",
},
},
minTtl: 0,
defaultTtl: 3600,
maxTtl: 86400,
compress: true,
viewerProtocolPolicy: "redirect-to-https",
},
],
priceClass: "PriceClass_All",
restrictions: {
geoRestriction: {
restrictionType: "none",
},
},
tags: {
Environment: pulumi.getStack(),
},
viewerCertificate: {
acmCertificateArn: appCert.arn,
cloudfrontDefaultCertificate: false,
minimumProtocolVersion: 'TLSv1.2_2021',
sslSupportMethod: 'sni-only'
},
});
// DNS record for CloudFront distribution
const appRecord = new aws.route53.Record("app", {
zoneId: hostedZoneId,
name: appCert.domainValidationOptions[0].domainName,
type: "CNAME",
ttl: 300,
records: [s3Distribution.domainName],
});
const s3PolicyDoc = aws.iam.getPolicyDocumentOutput({
statements: [{
actions: ["s3:GetObject"],
resources: [
bucketV2.arn,
pulumi.interpolate`${bucketV2.arn}/*`,
],
principals: [{
type: "AWS",
identifiers: [oai.iamArn],
}],
}],
});
const s3Policy = new aws.s3.BucketPolicy("s3Policy", {
bucket: bucketV2.id,
policy: s3PolicyDoc.apply(s3PolicyDoc => s3PolicyDoc.json),
});

GitHub Actions

Setup Environment Variables for the actions by going to “Settings” in the repository and adding the following “Repository secrets”

  • AWS_ACCESS_KEY_ID — Access key for IAM credentials
  • AWS_SECRET_ACCESS_KEY — Secret key for IAM credentials
  • PULUMI_ACCESS_TOKEN — Access token created in a Pulumi account

Create an “Environment” called dev and add the following secrets to it (You won’t have the values for the cloudfront distribution until after the deploy action has run for the first time and completed the Pulumi steps:

  • AWS_CLOUDFRONT_DIST — The ID of the CloudFront distribution created
  • AWS_S3_BUCKET — The AWS S3 bucket name created (In my case it is app.cjscloud.city
GitHub Action Secrets

Action to build

This actions runs on every push to a remote branch and will checkout the repo, install Node 14.x, install dependencies and run the build command of the react app.

name: Buildon: [push]jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- name: npm install and build
run: |
npm ci
npm run build --if-present
working-directory: esp32-pwa-app
env:
CI: true
- name: Archive production build artifacts
uses: actions/upload-artifact@v3
with:
name: pwa-build-artifact
path: |
esp32-pwa-app/build
Example of a Build action run

Actions to deploy

This action is triggered after the Build has successfully completed a run on the main branch. It made of the following two jobs:

infra

  • Checks out the repo
  • Installs Node 14.x
  • Configures the AWS credentials using the GitHub secrets we set earlier
  • Installs the Node packages in the infra directory of the repo
  • Runs pulumi up

deployment

This job runs after the infra job.

  • Downloads the build artifact from the build action
  • Configures AWS credentials
  • Copies the build files to the S3 bucket in AWS
  • Invalidates the CloudFront cache so that the new build is served (This step will fail the first time the action runs, as the AWS_CLOUDFRONT_DIST secret is currently empty, but can be updated as the infra job should have created the CloudFront distribution in the AWS account).
name: Deployon:
workflow_run:
workflows: ["Build"]
types:
- completed
branches:
- main
jobs:
infra:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Install infra dependencies
run: npm i
working-directory: infra
- name: Pulumi Up
uses: pulumi/actions@v3
with:
command: up
stack-name: dev
work-dir: infra
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
deployment:
needs: infra
runs-on: ubuntu-latest
environment: dev
steps:
- name: Download build artifact
uses: dawidd6/action-download-artifact@v2
with:
workflow: build.yaml
workflow_conclusion: success
branch: main
name: pwa-build-artifact
path: ./build
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Copy build to S3 bucket
run: |
aws s3 sync build/ s3://${{ secrets.AWS_S3_BUCKET }}
- name: Create CloudFront invalidation
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DIST }} --paths "/*"
Example of a deploy action run

Hope you found this useful!
Chur 🤙

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store