Create and Deploy a Vue SPA to  S3 + CloudFront using a multi-stage pipeline in Azure DevOps and AWS CloudFormation
Tags: devops, aws, vue, azure
Published: 2020-10-28 11:11:43

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](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-register.html) 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](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-request-public.html) 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](https://docs.aws.amazon.com/acm/latest/userguide/import-certificate.html) 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](https://cli.vuejs.org/guide/creating-a-project.html) 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](https://github.com/motdotla/dotenv#readme), 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] (https://github.com/aguschirico/spa-s3-cloufront-example/blob/main/serverless.template)

####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](https://dev-to-uploads.s3.amazonaws.com/i/idigjrkrt2iwqwh6dwko.png)

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](https://dev-to-uploads.s3.amazonaws.com/i/q9hk789hh65hmub1hyoo.png)

**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](https://dev-to-uploads.s3.amazonaws.com/i/fd8buzas6dev1czr7len.png)

An example yaml for each task can be found [here](https://github.com/aguschirico/spa-s3-cloufront-example/tree/main/release%20tasks)

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](https://dev-to-uploads.s3.amazonaws.com/i/kzyha01frn55izttg7a5.png)

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