Continuous Deployment of Nuxt on AWS with GitLab CI

Craft your way to serverless DevOps: set up a CI/CD pipeline to deploy automatically a NuxtJS application on AWS, using CloudFormation and GitLab CI

Kenny Durand
10 min readNov 29, 2021

Modern front-end frameworks like Vue or React allow developers to build fast and scalable applications. Yet developers are often left to dry when it comes to hosting problematics. A lot of PaaS companies fill that void by providing easy-to-configure tools to deploy front-end applications, taking into account recent frameworks.

But what happens when you want more control and customisation on your infrastructure? That was my dilemma at the start of year 2020, when I built the first bricks of what would become the technical stack of Iroko, our real estate investment solution.

I had (and still have!) big dreams for Iroko’s technical stack:

  • I wanted it as automated as possible, with a smart layer of DevOps, meaning at least continuous integration and deployment,
  • I wanted it serverless, in order to focus mainly on our feature’s added value rather than infrastructure monitoring. It is also the greenest way to do Web development to date, which aligns with Iroko’s ecological commitment,
  • I wanted it flexible, so we could adapt and expand easily from our MVP as our team grows.

A wide choice of technologies

These concerns led me to choose state-of-the-art tools to build Iroko:

  • VueJS, and its framework Nuxt, since they are one of the main JS framework at the moment, and allow server-side rendering (SSR) and pre-rendering.
  • Amazon Web Services, because it seemed the most flexible and advanced cloud provider to answer our ambitions of serverless and continuous development.
  • GitLab CI, because it’s a very powerful and customisable CI tool that I already had experience with.

Of course, each of these technologies have caveats and a lot of good alternatives exist, I will however focus in this tutorial to provide examples using them. Feel free to reuse my methodology and adapt it to your use case!

How to set this up in a few steps

As we worked hard on reaching our technical objectives at Iroko, we accumulated a substantial experience in automating our stack, so I’m happy to share a bit of our journey with you!

The following tutorial will help you build your dream of continuous deployment for your Nuxt application, but if you want to go straight to the point, feel free to use the small Git repository that I set up to create this article.

TL;DR: Clone this repository and start your project from it! => https://gitlab.com/KennyDurand/nuxt-on-aws/

And for the heavy readers out there, let’s start the step-by-step explanation!

Project setup

Nuxt application

Let us begin with creating the project. The best way for that is to follow the Nuxt documentation on installation.

npm init nuxt-app nuxt-on-aws
cd nuxt-on-aws

Follow the CLI to configure your project as you wish. The most important options are “Rendering mode”, that you should set to “Universal (SSR / SSG)”, and “Deployment target” that you should set to “Static”.

You should have a standard Nuxt application that you can run locally using npm run dev , and that will display by default on http://localhost:3000.

GitLab CI

Then, let’s push your application on a GitLab repository.

git add .
git commit -m "Init Nuxt application"
git remote add origin <YOUR_GIT_REPO>
git push -u origin master

Once it’s done, the magic of GitLab CI is that you only have to push a .gitlab-ci.ymlfile to get the Continous Integration to work.

In this tutorial, we will use a second file that will group all deploy-related actions, using GitLab CI’s trigger feature. It might sound overkill here, but it’s a good practice I advise you to follow. This helps keep your main file from being bloated with lots of different jobs (testing, building, deploying, cleaning up, etc.). Factorizing your CI pipelines makes it more readable as well.

First, let’s setup .gitlab-ci.yml which will trigger (for “staging” environment) the deploy pipeline whenever there is a new commit on branch “master” :

deploy-staging:
trigger:
include: ci/pipelines/deploy.yml
variables:
SUB_DOMAIN: staging
rules:
- if: $CI_COMMIT_BRANCH == "master"

Then, let’s create the deploy pipeline at ci/pipelines/deploy.yml :

stages:
- init
- build
- deploy
init-npm:
stage: init
image: node:16
script:
- npm ci
artifacts:
paths:
- node_modules/
build-nuxt:
stage: build
image: node:16
needs:
- job: init-npm
script:
- npm run generate
artifacts:
paths:
- dist/

As we can see, whenever a new commit is pushed, the deploy pipeline that is triggered will have two jobs: the first one will install all node_modules dependencies, the second will use these dependencies (thanks to the artifactsand needs keywords) to generate the static files of the application and store them into another artifacts folder.

If you commit and push right now, here is what you should see on GitLab CI:

The pipeline is triggered as soon as master is pushed
The content of the triggered pipeline shows the two jobs fore-mentioned.

After a few minutes, your pipeline should be green. Congratulations, you just built automatically your Nuxt application! Now it’s time to make it live!

AWS deployment

Amazon Web Services brings many great tools for our current need. Here is what we will be using:

The first step towards automatic deployment will be to set up your AWS account and to get your access key (ID and secret) in order to use the AWS CLI. You should add those values to the GitLab CI environment variables, named as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY . Those values will be automatically used by the CI Docker image to configure your AWS CLI. You can optionally add AWS_DEFAULT_REGION to select the region where to deploy your stack.

Once you have it, we can create CloudFormation stack templates in our project, in order to deploy the front-end application on AWS services.

This configuration assumes that you have a domain name registered on Route 53. For the rest of the tutorial, we will assume you have added two additional values to GitLab CI environment variables: ROOT_DOMAIN_NAME with your domain name and HOSTED_ZONE_ID with the ID of the hosted zone in AWS.

First, let’s begin with creating aci/cloudformation/ssl-certificate.yml file containing this:

AWSTemplateFormatVersion: '2010-09-09'
Description: Setup SSL Certificate
Parameters:
SubDomain:
Type: String
Description: Sub-domain to deploy
RootDomainName:
Type: String
Description: The root domain name
HostedZoneId:
Type: String
Description: Id of Route 53 hosted zone for root domain name
Resources:
SslCertificate:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: !Join [., [!Ref SubDomain, !Ref RootDomainName]]
DomainValidationOptions:
- DomainName: !Join [., [!Ref SubDomain, !Ref RootDomainName]]
HostedZoneId: !Ref HostedZoneId
SubjectAlternativeNames:
- !Join [., ['*', !Ref SubDomain, !Ref RootDomainName]]
ValidationMethod: DNS
Outputs:
SslCertificateArn:
Description: The ARN of the SSL certificate
Value: !Ref SslCertificate

The goal of this file is pretty straight-forward: create a SSL certificate and register it through a DNS record in your Route 53 zone.

Time to create another stack template, let’s create ci/cloudformation/nuxt-app.yml :

AWSTemplateFormatVersion: '2010-09-09'
Description: Deploying our Nuxt application
Parameters:
SubDomain:
Type: String
Description: Deployment environment name
RootDomainName:
Type: String
Description: The root domain name
SslCertificateArn:
Type: String
Description: ARN of the SSL certificate
HostedZoneId:
Type: String
Description: Id of Route 53 hosted zone for root domain name
Resources:
CloudFrontOAI:
Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
Properties:
CloudFrontOriginAccessIdentityConfig:
Comment: OAI to restrict S3 bucket to website URL
ApplicationBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Join ['.', [!Ref SubDomain, !Ref RootDomainName]]
AccessControl: Private
ApplicationBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ApplicationBucket
PolicyDocument:
Statement:
- Sid: PublicReadToServePages
Effect: Allow
Principal:
CanonicalUser: !GetAtt [CloudFrontOAI, S3CanonicalUserId]
Action: s3:GetObject
Resource: !Sub 'arn:aws:s3:::${ApplicationBucket}/*'
- Sid: ListObjectsToReturn404InsteadOf403
Effect: Allow
Principal:
CanonicalUser: !GetAtt [CloudFrontOAI, S3CanonicalUserId]
Action: s3:ListBucket
Resource: !Sub 'arn:aws:s3:::${ApplicationBucket}'
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Aliases:
- !Join ['.', [!Ref SubDomain, !Ref RootDomainName]]
CustomErrorResponses:
- ErrorCode: 404
ResponseCode: 404
ResponsePagePath: /
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
- OPTIONS
CachedMethods:
- GET
- HEAD
- OPTIONS
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized
Compress: true
ForwardedValues:
QueryString: true
OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf # Managed-CORS-S3Origin
TargetOriginId: S3BucketApplicationHosting
ViewerProtocolPolicy: redirect-to-https
DefaultRootObject: index.html
Enabled: true
HttpVersion: http2
Origins:
- S3OriginConfig:
OriginAccessIdentity: !Join ['', [origin-access-identity/cloudfront/, !Ref CloudFrontOAI]]
DomainName: !Sub "${ApplicationBucket}.s3.${AWS::Region}.amazonaws.com"
Id: S3BucketApplicationHosting
PriceClass: PriceClass_100
ViewerCertificate:
AcmCertificateArn: !Ref SslCertificateArn
MinimumProtocolVersion: TLSv1.2_2018
SslSupportMethod: sni-only
DNSRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZoneId
Name: !Join ['.', [!Ref SubDomain, !Ref RootDomainName]]
Type: A
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2 # Mandatory Hosted Zone for CloudFront distributions
DNSName: !GetAtt [CloudFrontDistribution, DomainName]
Outputs:
CloudFrontDistributionId:
Value: !Ref CloudFrontDistribution
Description: ID of the CloudFront distribution

This configuration creates several resources, in order:

  • An identity whose purpose is to protect S3 files from being accessed from anything other than CloudFront,
  • A S3 bucket to store the static files generated by Nuxt,
  • A S3 bucket policy, used to grant the identity access to those files,
  • A CloudFront distribution that will use the identity to expose the static website on the Web and handle its caching,
  • A DNS record that will direct the corresponding URL towards the CloudFront distribution.

Bear in mind there are a lot of configuration options, in particular CloudFront caching strategies, in order to enjoy the best level of performance for your front-end application.

Now, we only have to add a new GitLab CI job to launch the provisioning of all those resources. Add this to you ci/pipelines/deploy.yml :

deploy-frontend:
stage: deploy
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
script:
# 1. Deploy the SSL certificate (using region us-east-1 is mandatory to attach the certificate to CloudFront)
- aws cloudformation deploy --template-file ci/cloudformation/ssl-certificate.yml --stack-name $SUB_DOMAIN-ssl --parameter-overrides SubDomain=$SUB_DOMAIN RootDomainName=$ROOT_DOMAIN_NAME HostedZoneId=$HOSTED_ZONE_ID --region us-east-1 --no-fail-on-empty-changeset
- export SSL_CERTIFICATE_ARN=$(aws cloudformation describe-stacks --stack-name $SUB_DOMAIN-ssl --output json --query "Stacks[0].Outputs[?OutputKey == 'SslCertificateArn'] | [0].OutputValue" --region us-east-1 | tr -d \")
# 2. Deploy the Nuxt website (using the SSL certificate ARN that we retrieve from the stack)
- aws cloudformation deploy --template-file ci/cloudformation/nuxt-app.yml --stack-name $SUB_DOMAIN-nuxt --no-fail-on-empty-changeset --capabilities CAPABILITY_IAM --parameter-overrides SubDomain=$SUB_DOMAIN RootDomainName=$ROOT_DOMAIN_NAME SslCertificateArn=$SSL_CERTIFICATE_ARN HostedZoneId=$HOSTED_ZONE_ID
# 3. Copy the files to the newly created S3 bucket
- export BUCKET_NAME=$SUB_DOMAIN.$ROOT_DOMAIN_NAME
- aws s3 sync ./dist s3://${BUCKET_NAME} --cache-control max-age=31536000
# 4. In order to avoid trimming "index.html" in your website, remove those suffixes from all S3 keys that have it
- |
for o in `aws s3api list-objects --bucket ${BUCKET_NAME} --query "Contents[?contains(Key, '/index.html')].Key" --output text`;
do n=`echo ${o} | sed -e "s/\/index.html//g"`;
aws s3 mv s3://${BUCKET_NAME}/${o} s3://${BUCKET_NAME}/${n} --cache-control s-max-age=31536000
done;
# 5. Invalidate the CloudFront cache to refresh the website when it changes
- export CLOUDFRONT_ID=$(aws cloudformation describe-stacks --stack-name $SUB_DOMAIN-nuxt --output json --query "Stacks[0].Outputs[?OutputKey == 'CloudFrontDistributionId'] | [0].OutputValue")
- aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID//\"} --paths "/*"
environment:
name: $SUB_DOMAIN

This job looks very complex at first, but diving inside helps us understand how it works:

  1. AWS creates the SSL certificate thanks to your first CloudFormation stack, targeted on region us-east-1, and you retrieve the certificate ARN to use it in your second stack,
  2. Then, AWS instantiates all the resources of your second stack, using the given ARN,
  3. Among those resources, you created a S3 bucket where you can upload the Nuxt static files,
  4. To handle routing in Nuxt in a good way, you need to replace the “index.html” files from their parent key, so that “/path/index.html” becomes “/path” in the eyes of the S3 static hosting,
  5. Finally, with the reinforced cache strategy we use on CloudFront invalidating the cache is mandatory to be able to see the changes on the website.

That should be it, you can now push and let the CI magic operate. The first deployment should be a bit longer than the next ones, but after a while…

Your Nuxt application should be available on the given URL!

Congratulations, your Nuxt application is live!

Bonus : Handle several environments

The cherry on the cake with AWS CloudFormation, is that you can very easily replicate a stack for another environment with a set of different parameters. And GitLab CI is also very good to run similar jobs in different contexts and with different settings!

With that in mind, deploying your production environment when you push a tag is very simple: just add a new trigger to the deploy pipeline in your GitLab CI configuration. In .gitlab-ci.yml , add this:

deploy-prod:
trigger:
include: ci/pipelines/deploy.yml
variables:
SUB_DOMAIN: www
rules:
- if: $CI_COMMIT_TAG

Commit and push this modification as a tag to see a new environment rise up… You can literally have an army of environments at your service!

Conclusion

As you can see, with about 200 lines of configuration, you can build a very effective CI/CD process for your front-end applications. No doubt you can also apply the same kind of concepts with other technologies like React or Angular, and with other CI tools like GitHub Actions or CircleCI. Feel free to experiment!

You can also apply the same kind of concepts for back-end applications… But we’ll talk about that in another article! :)

If you liked this article and want to have a chat with me, feel free to contact me on Twitter (@Durand_Kenny), and read my (future) other articles on Medium (Kenny Durand).

--

--