Skip to content
Efficience IT
FR
·6 min read·JavaScriptRecently updated

Deploy Nuxt.js with GitLab CI, S3 and CloudFront

By Louis-Arnaud Catoire

Updated on

Deploy Nuxt.js with GitLab CI, S3 and CloudFront

Deploying a static site generated by Nuxt.js on AWS infrastructure is more than pushing files to a bucket. Behind this apparent simplicity lie architecture decisions that affect the performance, security and resilience of your platform. This article walks the full path: from the initial S3 + CloudFront setup to multi-environment deployment strategies, including CDN architecture considerations at scale.

The AWS Foundation: S3 and CloudFront

The principle is clear. S3 stores the static files (HTML, CSS, JS, images). CloudFront distributes them through a network of points of presence spread around the world. Before choosing Nuxt.js, it can be useful to compare the available JavaScript frameworks (in French) to make sure this deployment infrastructure matches the right tool. Our Vue.js development (in French) offering covers Nuxt and SSR. A dedicated IAM user lets GitLab CI interact with these services programmatically.

Configuring the S3 Bucket

Creating the bucket follows a precise sequence. In the S3 console, create a bucket whose name will be stored in the AWS_BUCKET_NAME variable. Unblock public access, then enable static website hosting in the properties, specifying index.html as the index document.

The bucket policy must allow public read access:

{
   "Version": "2012-10-17",
   "Statement": [
       {
           "Sid": "PublicReadGetObject",
           "Effect": "Allow",
           "Principal": "*",
           "Action": "s3:GetObject",
           "Resource": "arn:aws:s3:::nuxt-website-efficience-it/*"
       }
   ]
}

A 404 error on index.html after creation is the expected behavior: the bucket is functional, but empty.

CloudFront Distribution

CloudFront acts as a cache layer between users and the S3 bucket. Beyond the performance gain, this architecture significantly reduces the load on the origin: every request served from the cache avoids a call to S3.

When creating the distribution, attach it to the S3 bucket, force HTTP to HTTPS redirection and associate your domain name with its SSL certificate. Keep the distribution ID in AWS_CLOUDFRONT_INVALIDATION_ID: you will need it to purge the cache during deployments.

The gitlab-runner IAM User

The principle of least privilege applies here. Create a dedicated IAM user with only two permissions: AmazonS3FullAccess for file synchronization and a custom IAM policy with the cloudfront:CreateInvalidation action for cache purging. Generate a programmatic access key that will feed the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY variables in GitLab.

A point of caution for production environments: prefer IAM policies restricted to the specific bucket rather than AmazonS3FullAccess, which grants access to every bucket in the account.

GitLab CI Pipeline: Build and Deployment

The Build Stage

The build generates the static files from the Nuxt.js source code. The resulting artifact (the /dist folder) is consumed by the deployment stage.

stages:
   - build
cache:
   paths:
       - node_modules/

build_site:
   image: node:lts
   before_script:
       - npm install
   script:
       - npm run generate --fail-on-error --quiet --no-optional
   artifacts:
       paths:
           - dist

Caching node_modules/ is essential. Without it, every pipeline reinstalls all dependencies, a problem you also see when managing dependencies with Composer (in French). On a typical Nuxt.js project, that easily saves two to three minutes per run. For a team that pushes regularly, the cumulative gain is considerable.

The Deployment Stage

The deployment performs two atomic operations: syncing files to S3, then invalidating the CloudFront cache.

stages:
   - deploy

variables:
   AWS_DEFAULT_REGION: eu-central-1

deploy_s3:
   image: python:latest
   stage: deploy
   before_script:
       - pip install awscli
   script:
       - aws s3 sync dist s3://${AWS_BUCKET_NAME} --delete --cache-control "max-age=31536000" --expires 2030-01-01T00:00:00Z
       - aws cloudfront create-invalidation  --distribution-id ${AWS_CLOUDFRONT_INVALIDATION_ID} --paths "/*"

The --delete flag removes from the bucket any files absent from the local build, guaranteeing strict synchronization. The cache-control and expires headers tell browsers to cache the assets for one year, which maximizes client-side performance. Since Nuxt.js generates hashed file names for CSS and JS, old files will never be served by mistake.

CI/CD Variables

In GitLab, under Settings > CI/CD > Variables, inject the four secrets: AWS_CLOUDFRONT_INVALIDATION_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_BUCKET_NAME. Mark them as protected and masked to prevent any leak into pipeline logs. Rigorous secrets management is part of the best practices for protecting yourself against security vulnerabilities (in French).

Multi-Environment Deployment by Branch

A production pipeline is not enough. A well-organized team needs preview environments to validate changes before going to production. The principle: one bucket/CloudFront pair per environment, with the pipeline routing the deployment automatically according to the branch.

The master branch triggers the production deployment. The dev branch deploys automatically to the development environment. Feature branches offer a manual deployment through the GitLab interface, letting you preview a change without polluting the dev environment.

The Final .gitlab-ci.yml File

stages:
   - build
   - deploy
cache:
   paths:
       - node_modules/
.build_site:
   stage: build
   image: node:lts
   before_script:
       - npm install
   artifacts:
       paths:
           - dist

build_site_prod:
   extends: .build_site
   script:
       - npm run generate --fail-on-error --quiet --no-optional
   only:
       - master

build_site_dev:
   extends: .build_site
   script:
       - npm run generate --fail-on-error --quiet --no-optional
   only:
       - dev

build_site_branch:
   extends: .build_site
   script:
       - npm run generate --fail-on-error --quiet --no-optional
   when: manual
   except:
       - master
       - dev

variables:
   AWS_DEFAULT_REGION: eu-central-1

.deploy_s3:
   image: python:latest
   stage: deploy
   before_script:
       - pip install awscli

deploy_s3_branch:
   needs:
       - build_site_branch
   extends: .deploy_s3
   when: on_success
   except:
       - master
       - dev
   script:
       - aws s3 sync dist s3://dev.exemple.com --delete --cache-control "max-age=31536000" --expires 2030-01-01T00:00:00Z
       - aws cloudfront create-invalidation  --distribution-id ${AWS_CLOUDFRONT_INVALIDATION_ID_DEV} --paths "/*"
   environment:
       name: development
       url: https://dev.exemple.com

deploy_s3_dev:
   needs:
       - build_site_dev
   extends: .deploy_s3
   when: on_success
   only:
       - dev
   script:
       - aws s3 sync dist s3://dev.exemple.com --delete --cache-control "max-age=31536000" --expires 2030-01-01T00:00:00Z
       - aws cloudfront create-invalidation  --distribution-id ${AWS_CLOUDFRONT_INVALIDATION_ID_DEV} --paths "/*"
   environment:
       name: development
       url: https://dev.exemple.com

deploy_s3_prod:
   extends: .deploy_s3
   only:
       - master
   before_script:
       - pip install awscli
   script:
       - aws s3 sync dist s3://www.exemple.com --delete --cache-control "max-age=31536000" --expires 2030-01-01T00:00:00Z
       - aws cloudfront create-invalidation  --distribution-id ${AWS_CLOUDFRONT_INVALIDATION_ID_PROD} --paths "/*"
   environment:
       name: production
       url: https://www.exemple.com

Using YAML templates (.build_site, .deploy_s3) avoids duplication and eases maintenance. The needs keyword creates explicit dependencies between jobs, letting GitLab optimize pipeline scheduling.

Need support on your project?

Let's talk

Cache Invalidation Strategies

The --paths "/*" invalidation is the simplest method but also the heaviest: it purges the entire cache on every deployment. To get a good grip on the caching strategies that complement the CloudFront CDN (in French), it is essential to have a global view of caching on the server, browser and network sides.

Two alternative approaches exist. The first is to invalidate only the modified files by comparing the current build with the bucket contents via aws s3 sync --dryrun, then targeting the relevant paths. The second, more elegant, relies on asset versioning: since Nuxt.js generates hashed names, only index.html and fixed-path files need invalidation. Targeted invalidation of a few files is free and instant.

CDN Architecture: Going Beyond the Initial Setup

Blue-Green Deployment With CloudFront

For high-traffic sites that need reliable hosting (in French), blue-green deployment eliminates all downtime. The principle: maintain two S3 buckets (blue and green), deploy to the inactive bucket, then switch the origin of the CloudFront distribution. If something goes wrong, rollback is immediate since the old bucket remains intact.

CloudFront natively supports origin groups with automatic failover. If the primary origin returns a 5xx error, CloudFront transparently switches to the secondary origin. This mechanism, combined with blue-green, offers remarkable resilience.

Multi-Region and Latency

CloudFront already distributes content globally, but the source S3 bucket stays in a single region. For geographically dispersed audiences, S3 cross-region replication brings the source files closer to CloudFront edge locations, reducing latency on cache misses.

The choice of the primary region also affects the latency of data transfers between the origin and CloudFront.

Infrastructure as Code With Terraform

At scale, manually creating buckets and distributions through the console becomes a drag. Terraform lets you describe the entire infrastructure (S3, CloudFront, IAM, Route 53, ACM) in versioned files. Each environment becomes reproducible, auditable and destroyable with a single command. The Terraform state, itself stored in a dedicated S3 bucket, is the source of truth for the infrastructure.

This approach radically transforms the workflow: creating a new preview environment per merge request no longer requires manual intervention in the AWS console, just a simple terraform apply parameterized by the branch name. This kind of automation is part of our Cloud and DevOps (in French) offering for industrializing deployment pipelines. For Symfony applications, our Symfony hosting offering builds in these automated deployment best practices from the moment the infrastructure is set up.

Distribution Optimization

Three main levers help you optimize this type of architecture. The first is the CloudFront delivery scope: limiting distribution to North America and Europe is often enough for a regional audience. The second is the cache policy: a high TTL on immutable assets reduces origin requests and therefore the load on S3. The third is lifecycle management on S3: configuring automatic deletion rules for old files prevents the silent accumulation of obsolete data.

Going Further

Have a project in mind?

Our team replies within 48 hours to look at your needs and suggest a suitable approach.

Contact us

Related articles