The Nuxt logo in black and mint green with a vector image of a rocket ship taking off to the right.

Deploy a Nuxt.js Site to AWS Using CDK

Automated deployments with feature branches and staged environments doesn't require an expensive deployment platform provider. Deploy your Nuxt.js sites directly to AWS infrastructure using AWS Cloud Development Kit (CDK) instead.

Deploying a Nuxt.js site to AWS using the AWS Cloud Development Kit (CDK) streamlines the deployment process without incurring additional costs from deployment platforms. The static site assets will be deployed to an S3 bucket and distributed via CloudFront CDN. Dynamic assets will be generated by a Lambda function and cached by the same CloudFront CDN.

Step 1: Install the Required Packages

To begin, you’ll need to install the following packages:

Run the following command to install them:

npm install --save-dev aws-cdk aws-cdk-lib @trautonen/cdk-dns-validated-certificate tsx constructs

This will install the CDK core libraries and the tools to manage SSL certificates and construct abstractions. These packages will allow your Nuxt.js app to integrate seamlessly with AWS infrastructure, while tsx lets us write our CDK files in TypeScript.

Step 2: Update Nuxt Configuration for AWS Lambda

To ensure your Nuxt.js app deploys correctly on AWS Lambda, you need to adjust the nitro.preset in the nuxt.config.ts file to target aws-lambda.

export default defineNuxtConfig({
  nitro: { 
    preset: 'aws-lambda'
  }
})

Nuxt.js’ Nitro framework includes the ability to run on various serverless platforms, and the aws-lambda preset ensures your app is packaged for Lambda deployment. This shift is crucial since AWS Lambda behaves differently than traditional servers, especially in terms of request handling and environment lifecycles.

Step 3: Create a cdk.json Configuration File

Next, we need to tell CDK how to bootstrap the application. Create a cdk.json file in the root of your project with the following content:

{
  "app": "npx tsx ./deployment/index.ts",
  "watch": {
    "include": [
      "**"
    ],
    "exclude": [
      "README.md",
      "cdk*.json",
      "**/*.d.ts",
      "**/*.js",
      "tsconfig.json",
      "package*.json",
      "yarn.lock",
      "node_modules",
      "test",
      ".husky",
      ".vscode",
      ".nuxt"
    ]
  }
}

This file tells CDK to execute index.ts from the deployments folder as the entry point for deployment. Here, tsx handles TypeScript execution, simplifying our deployment setup.

Step 4: Create the Deployment Directory

To organize the deployment logic, create a deployments directory. This will store the infrastructure definitions that the AWS CDK will use to build and deploy resources like S3, CloudFront, and Lambda. Inside this directory, add two files: index.ts and nuxtSite.ts.

/deployments
 ├── index.ts 
 └── nuxtSite.ts

Step 5: Define the Cloud Resources in nuxtSite.ts

The nuxtSite.ts file defines how AWS resources like S3, CloudFront, and Lambda interact to host your Nuxt.js application. This setup ensures that the static assets are served via an S3 bucket, and CloudFront distributes them globally, deploys the server runtime as a Lambda function, and generates the required SSL certificates to serve the site over HTTPS.

import type { Stack } from 'aws-cdk-lib'
import type { IGrantable } from 'aws-cdk-lib/aws-iam'

import { DnsValidatedCertificate } from '@trautonen/cdk-dns-validated-certificate'
import { CfnOutput, Duration, RemovalPolicy } from 'aws-cdk-lib'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins'
import { Architecture, AssetCode, Function, FunctionUrlAuthType, Runtime } from 'aws-cdk-lib/aws-lambda'
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as targets from 'aws-cdk-lib/aws-route53-targets'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
import { Construct } from 'constructs'

import { DeploymentType } from '.'

export interface NuxtSiteProps {
  /**
   * The type of deployment for the Nuxt.js site.
   */
  readonly deploymentType: DeploymentType

  /**
   * The domain name for the Nuxt.js site.
   */
  readonly domainName: string

  /**
   * The environment variables for the Lambda Function.
   *
   * * @default - none
   */
  readonly environment?: Record<string, string>

  /**
   * Should the S3 and RDS retain data when the stack is destroyed?
   */
  readonly shouldRetainData: boolean

  /**
   * The subdomain for the Nuxt.js site.
   */
  readonly siteSubDomain: null | string | undefined
}

export class NuxtSite extends Construct {
  /**
   * The Lambda Function that serves the Nuxt.js site.
   */
  readonly lambdaFunctionGrantable: IGrantable

  constructor(parent: Stack, name: string, props: NuxtSiteProps) {
    super(parent, name)
    const isProd = props.deploymentType === DeploymentType.Production
    const isStage = props.deploymentType === DeploymentType.Stage

    const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.domainName })
    const siteDomain = props.siteSubDomain ? `${props.siteSubDomain}.${props.domainName}` : props.domainName

    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      autoDeleteObjects: !props.shouldRetainData,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false,
      }),
      bucketName: siteDomain,
      objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
      publicReadAccess: true,
      removalPolicy: props.shouldRetainData ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
      websiteIndexDocument: 'index.html',
    })

    const lambdaFunction = new Function(this, 'LambdaFunction', {
      architecture: Architecture.X86_64,
      code: new AssetCode('./.output/server'),
      environment: props.environment,
      handler: 'index.handler',
      memorySize: 1024,
      runtime: Runtime.NODEJS_20_X,
      timeout: Duration.seconds(30),
    })

    const lambdaFunctionUrl = lambdaFunction.addFunctionUrl({ authType: FunctionUrlAuthType.NONE })

    const certificate = new DnsValidatedCertificate(this, 'SiteCertificate', {
      certificateRegion: 'us-east-1',
      domainName: siteDomain,
      validationHostedZones: [
        {
          hostedZone: zone,
        },
      ],
    })

    const lambdaFunctionOrigin = new cloudfront_origins.FunctionUrlOrigin(lambdaFunctionUrl)
    const s3SiteOrigin = new cloudfront_origins.HttpOrigin(siteBucket.bucketWebsiteDomainName, {
      protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
    })

    const requestPolicy = new cloudfront.OriginRequestPolicy(this, 'OriginRequestPolicy', {
      cookieBehavior: cloudfront.OriginRequestCookieBehavior.all(),
      headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList(
        'Origin',
        'Content-Type',
      ),
      queryStringBehavior: cloudfront.OriginRequestCookieBehavior.all(),
    })

    const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
      additionalBehaviors: {
        '/api/*': {
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          compress: true,
          origin: lambdaFunctionOrigin,
          originRequestPolicy: requestPolicy,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        '/api/_content/*': {
          allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
          compress: true,
          origin: s3SiteOrigin,
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
      },
      certificate: certificate,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        compress: true,
        origin: new cloudfront_origins.OriginGroup({
          fallbackOrigin: lambdaFunctionOrigin,
          fallbackStatusCodes: [ 404, 403 ],
          primaryOrigin: s3SiteOrigin,
        }),
        originRequestPolicy: requestPolicy,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      domainNames: [ siteDomain ],
      minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
    })

    new route53.ARecord(this, 'SiteAliasRecord', {
      recordName: siteDomain,
      target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
      zone,
    })

    new s3deploy.BucketDeployment(this, 'DeployShortLivedAssets', {
      cacheControl: isProd
        ? [
            s3deploy.CacheControl.maxAge(Duration.hours(1)),
            s3deploy.CacheControl.staleWhileRevalidate(Duration.minutes(5)),
          ]
        : isStage
          ? [
              s3deploy.CacheControl.maxAge(Duration.minutes(5)),
              s3deploy.CacheControl.staleWhileRevalidate(Duration.minutes(1)),
            ]
          : [
              s3deploy.CacheControl.maxAge(Duration.seconds(0)),
              s3deploy.CacheControl.noCache(),
              s3deploy.CacheControl.noStore(),
              s3deploy.CacheControl.mustRevalidate(),
            ],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: [ '/*' ],
      exclude: [ '_nuxt/*', '_fonts/*' ],
      prune: true,
      sources: [ s3deploy.Source.asset('./.output/public') ],
    })

    new s3deploy.BucketDeployment(this, 'DeployLongLivedAssets', {
      cacheControl: isProd || isStage
        ? [
            s3deploy.CacheControl.maxAge(Duration.days(30)),
            s3deploy.CacheControl.staleWhileRevalidate(Duration.days(1)),
          ]
        : [
            s3deploy.CacheControl.maxAge(Duration.days(1)),
            s3deploy.CacheControl.staleWhileRevalidate(Duration.hours(1)),
          ],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: [ '/_nuxt/*', '/_fonts/*' ],
      exclude: [ '*' ],
      include: [ '_nuxt/*', '_fonts/*' ],
      prune: false,
      sources: [ s3deploy.Source.asset('./.output/public') ],
    })

    /**
     * Setting class properties
     */
    this.lambdaFunctionGrantable = lambdaFunction

    new CfnOutput(this, 'Site', { key: 'Site', value: 'https://' + siteDomain })
  }
}

This script will likely show an error for the DeploymentType import as this does not exist yet, but we’ll get to that next!

Step 6: Set Up the Deployment Logic in index.ts

Now, configure the index.ts file, which is responsible for initializing your CDK app and passing the necessary parameters (like domain name and certificate ARN) to the NuxtSite construct:

import * as cdk from 'aws-cdk-lib'

import { NuxtSite } from './nuxtSite'

const envWest = { account: '12345678', region: 'us-west-2' }
const developmentDomain = 'test.com'
const productionDomain = 'example.com'

export const enum DeploymentType {
  Development = 'Development',
  Production = 'Production',
  Stage = 'Stage',
}

export interface NuxtStackProps extends cdk.StackProps {
  readonly branchName?: string
  readonly deploymentType: DeploymentType
  readonly releaseVersion?: string
}

class NuxtStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: NuxtStackProps) {
    const { branchName, deploymentType } = props
    const isProd = deploymentType === DeploymentType.Production
    const isStage = deploymentType === DeploymentType.Stage

    const envAbbreviation = isProd ? 'prod' : isStage ? 'stage' : 'dev'
    const shouldRetainData = isProd || isStage

    const domainName = isProd ? productionDomain : developmentDomain
    const siteSubDomain = isProd ? null : isStage ? 'stage.site' : `${branchName}.site`
    const siteOrigin = !siteSubDomain ? `https://${domainName}` : `https://${siteSubDomain}.${domainName}`

    const stackProps: cdk.StackProps = {
      ...props,
      crossRegionReferences: true,
      description: `Nuxt Example - Site ${deploymentType}${branchName ? ` (${branchName.toUpperCase()})` : ''}`,
      stackName: `nuxt-example-site-${envAbbreviation}${branchName ? `-${props.branchName}` : ''}`,
      tags: {
        'Environment': deploymentType,
        'IaC': 'CDK',
        'Stack': 'Nuxt Example Site',
      },
    }

    super(scope, id, stackProps)

    const nuxtSite = new NuxtSite(this, 'NuxtSite', {
      deploymentType,
      domainName,
      environment: {
        NUXT_PUBLIC_RELEASE_VERSION: props.releaseVersion ?? `0.0.0-${envAbbreviation}`,
        NUXT_PUBLIC_SITE_ORIGIN: siteOrigin,
      },
      shouldRetainData,
      siteSubDomain,
    })

  }
}

const app = new cdk.App()

const environment: DeploymentType | undefined = app.node.tryGetContext('env')
const branchName: string | undefined = app.node.tryGetContext('branch')?.toString().toLowerCase()
const releaseVersion: string | undefined = app.node.tryGetContext('version')?.toString().replace(/^v/, '')

if (!environment) {
  throw new Error('Please provide an environment context value')
}

if (environment === DeploymentType.Development && !branchName) {
  throw new Error('Please provide a branch context value')
}

const envShortName = environment === DeploymentType.Production ? 'Prod' : environment === DeploymentType.Stage ? 'Stage' : 'Dev'
const stackName = `Nuxt-Example-Site-${envShortName}${branchName ? `-${branchName.toUpperCase()}` : ''}`

new NuxtStack(app, stackName, {
  branchName,
  deploymentType: environment,
  env: envWest,
  releaseVersion,
})

Replace the account, region, developmentDomain and productionDomain with your deployment details.

Step 7: Add Deployment Scripts to package.json

To simplify deployment, add commands to your package.json under the scripts section:

{
  "scripts": {
    "deploy:dev": "npx aws-cdk deploy --all -c env=Development -c branch=dev --ci --hotswap-fallback --require-approval never ",
    "deploy:stage": "npx aws-cdk deploy --all -c env=Stage --ci --require-approval never",
    "deploy:production": "npx aws-cdk deploy --all -c env=Production --ci --require-approval never",
  }
}

These commands allow for the easy deployment of the Nuxt.js site in multiple modes and environments. The deploy:dev script can be modified to support a branch specifier to allow deployments of PRs or Ad-Hoc branches.

Conclusion

By following these steps, you’ve set up a fully automated deployment pipeline for your Nuxt.js site using AWS Lambda, S3, and CloudFront, all managed with AWS CDK. With this approach, you can take advantage of serverless architecture, making it easy to scale and manage your application on AWS.

By integrating Nuxt.js’ Nitro framework with AWS CDK, you now have a flexible, highly available solution for deploying modern serverless websites. Happy deploying!