DEV Community

Cover image for Create and Deploy a Vue SPA to  S3 + CloudFront using a multi-stage pipeline in Azure DevOps and AWS CloudFormation
Augusto Chirico
Augusto Chirico

Posted on

Create and Deploy a Vue SPA to S3 + CloudFront using a multi-stage pipeline in Azure DevOps and AWS CloudFormation

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.  
Enter fullscreen mode Exit fullscreen mode
A mapping suffix for the s3 website
Mappings:
  RegionS3Suffix:
     eu-central-1:
      Suffix: .s3-website.eu-central-1.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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:
Alt Text

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.

Alt Text

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:

Alt Text

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:

Alt Text

I hope this was helpful guys, thanks for reading and enjoy your coding journey!

Top comments (1)

Collapse
 
danywalls profile image
Dany Paredes

Good!