Hosting a Single Page Application in S3 with CloudFront is one of the coolest things you may want to do as a full stack developer, specially considering how much cheaper and more stable your app will be, requiring you no maintenance at all and with unlimited scalability.
SPA's are a really powerful way to approach modern web applications development, and combined with AWS Services like S3 and CloudFront can raise you to fame really quickly as a successful developer. However, it can become really tough to deliver your SPA in real world scenarios, where you need to handle environment variables, CI/CD or you simply want to avoid configuring lots of things by hand in order to get your app in your customers hands.
I've seen really ugly (and even risky) hacks when it comes to deliver a multi-stage SPA setups, and in this post I'll show you one of the cleanest ways I could find so far to deliver an SPA through a fast pipeline that makes my life easy and users happy.
In this post I'll take you step by step to a fully automated delivery process in Azure DevOps for your SPA. I chose VueJS for the example the same approach will work with any other technology, and I chose Azure DevOps but you can translate the same steps to the CI/CD platform of your preference.
Ok my friend, let's get hands-on!!
Can't start without a Domain, right? (skip if you have a domain and a certificate in ACM us-east-1)
In order to deliver your app to the internet, you'll need a domain to use with it, let's say your domain is eureka.com and you have bought it through AWS. You'll need to request a certificate for your site. Tip: CloudFront only works with certificates hosted in us-east-1, so when you request the certificate, make sure you do it for that zone. If your domain is not issued by AWS, don't worry you can import your certificate pretty easily.
Now that you have a domain, you need an app (skip if you have an app already).
If you're an average developer I'll skip this step so you must know fairly well how to create a spa using a terminal. In the case of VueJS, the cli documentation explains it way better than I would ever do, so I won't waste your time with useless code blocks here.
Hey, our app is a classic app that connects to an api, so we need to connect to an API, remember? (skip if you... no, read this one up)
Of course, the way to connect to a backend is quite important right? Here's where the thing becomes interesting... In our app we'll have a different api per stage, so the url will differ between local, dev, staging, live, whatever.
I chose dotenv, which is one of the simplest and most widely used ways to store env vars in spa's. I created 2 dotenv files: .env.local (local and .gitignored) and .env.cd (pipeline)
The first file contains my local env vars. The second one is the one we'll use in our pipeline and will contain tokens instead of real variables, as follows:
while .env.local says:
VUE_APP_BACKEND_URL=https://mybackend-rocks.eureka.com
our .env.cd says:
VUE_APP_BACKEND_URL=#{backendUrl}#
The magical CloudFormation template.
We'll create a file called serverless.template (a classical name for a CloudFormation template yaml/json) that we'll use in the pipeline later on.
The template starts with the 3 params we need: the BucketName, the Route53 HostedZone and the certificate id we created previously.
Parameters:
BucketName:
Type: String
Description: The name for the bucket that will be deployed
HostedZone:
Type: String
Description: The DNS name of an existing Amazon Route 53 hosted zone
AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
ConstraintDescription: must be a valid DNS zone name.
CertificateId:
Type: String
Description: The Id of the certificate to be used
AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(?<!-)
ConstraintDescription: must be a valid Certificate Id in us east 1.
A mapping suffix for the s3 website
Mappings:
RegionS3Suffix:
eu-central-1:
Suffix: .s3-website.eu-central-1.amazonaws.com
Please note my bucket is in eu-central-1. You can choose the region of your preference.
The resources (the actual infra)
Resources:
S3BucketForWebApp:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
BucketName: !Ref BucketWebSiteName
WebsiteConfiguration:
IndexDocument: index.html
ErrorDocument: error.html
AppCDNDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Comment: CDN for S3-backed spa
Aliases:
- !Join ['', [!Ref 'AWS::StackName',
., !Ref 'HostedZone']]
Enabled: 'true'
DefaultCacheBehavior:
ForwardedValues:
QueryString: 'true'
TargetOriginId: only-origin
ViewerProtocolPolicy: allow-all
DefaultRootObject: index.html
Origins:
- CustomOriginConfig:
HTTPPort: '80'
HTTPSPort: '443'
OriginProtocolPolicy: http-only
DomainName: !Join ['', [!Ref 'S3BucketForWebApp', !FindInMap [RegionS3Suffix,
!Ref 'AWS::Region', Suffix]]]
Id: only-origin
ViewerCertificate:
AcmCertificateArn: !Sub 'arn:aws:acm:us-east-1:[your-aws-account-id]:certificate/${CertificateId}'
MinimumProtocolVersion: 'TLSv1'
SslSupportMethod: 'sni-only'
WebsiteDNSRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneName: !Join ['', [!Ref 'HostedZone', .]]
Comment: CNAME redirect custom name to CloudFront distribution
Name: !Join ['', [!Ref 'AWS::StackName',
., !Ref 'HostedZone']]
Type: CNAME
TTL: '180'
ResourceRecords:
- !GetAtt [AppCDNDistribution, DomainName]
Outputs of the stack
Outputs:
WebsiteURL:
Value: !Join ['', ['http://', !Ref 'WebsiteDNSName']]
Description: The URL of the newly created website
BucketName:
Value: !Ref 'S3BucketForWebsiteContent'
Description: Name of S3 bucket to hold website content
CloudFrontDistributionID:
Description: 'CloudFront distribution ID'
Value: !Ref WebsiteCDN
A complete version of the template can be found in this github repo
ok, commit, push, and...?
It's time to put your DevOps hat on, and configure the pipeline.
Build Pipeline
In Azure DevOps we'll create a build pipeline that basically copies the content of our app's folder into the drop artifact.
The copy task as follows:
And the Publish Artifact as usual:
Path to publish: $(Build.ArtifactStagingDirectory)
Artifact name: drop
Release Pipeline
Our release pipeline will take care of the following tasks:
1. Create/update the stack:
This task will execute the template and create your infra in AWS.
2. Replace the tokens in our env.cd file
This task will replace our #{backendUrl}# values with the values we set in the pipeline variables.
3. Rename .env.cd to .env
.env.cd will become .env so the app is build with the correct values.
4. npm/yarn install
Nothing to say.
5. npm/yarn run build
Same.
6. clean up the bucket
To avoid unnecessary extra cost for storing unused files.
7. upload the new compiled version to the bucket
It's basically copying your compiled "dist" folder onto the S3 bucket
8. invalidate CloudFront cache
CloudFront cache keeps alive for 24 hs, so in case you update your app and the files don't exist anymore, it will try to serve them. You invalidate the cache so CloudFront gets the newly updated files correctly.
To summarize, this is how your pipeline will look like:
An example yaml for each task can be found here
Alright, once you have created the tasks, you can create a task group so you reuse your tasks per each stage. After creating your release stages, the pipeline will end up looking like this:
I hope this was helpful guys, thanks for reading and enjoy your coding journey!
Top comments (1)
Good!