DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Nuxt 3 en AWS Lambda deployment completo con Nitro y Terraform

Nuxt 3 con Nitro tiene un truco que Next.js no: el mismo código se compila a diferentes runtimes. Un preset para Lambda, otro para Cloudflare Workers, otro para Node standalone. Para equipos en AWS significa que puedes desplegar Nuxt en Lambda con zero configuración adicional. Este artículo cubre el setup completo con Terraform (porque no todos usan CDK), incluyendo SSR, API routes, nitro middleware y los pain points específicos de Nuxt en serverless.

Por qué Nuxt + Nitro funciona tan bien

flowchart LR
    Nuxt[Nuxt 3 app] --> Nitro[Nitro build]
    Nitro --> Preset{Preset selection}
    Preset -->|aws-lambda| Lambda[Lambda handler]
    Preset -->|node-server| Node[Node.js app]
    Preset -->|cloudflare| CF[Cloudflare Worker]
    Preset -->|vercel| Vercel[Vercel edge]
    Preset -->|netlify| NL[Netlify function]

    Lambda --> ZIP[Optimized ZIP<br/>minified + tree-shaken]

    style Nitro fill:#00DC82,color:#000
    style ZIP fill:#ff9900,color:#000
Enter fullscreen mode Exit fullscreen mode

El build para Lambda genera un ZIP de ~15MB (vs ~50MB de Next.js standalone). Menos cold start, menos bandwidth de deploy.

Configurando Nuxt para Lambda

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2024-11-01',
  devtools: { enabled: true },

  ssr: true,

  nitro: {
    preset: 'aws-lambda',
    awsLambda: {
      streaming: true,
    },
    prerender: {
      crawlLinks: true,
      routes: ['/', '/sitemap.xml', '/robots.txt'],
    },
    routeRules: {
      '/': { prerender: true },
      '/blog/**': { isr: 3600 },
      '/api/**': { cors: true },
      '/admin/**': { ssr: false }, // SPA para admin
    },
  },

  modules: [
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
    '@nuxtjs/i18n',
    '@vueuse/nuxt',
  ],

  runtimeConfig: {
    // Solo server
    dynamoTable: process.env.DYNAMO_TABLE,
    cognitoClientId: process.env.COGNITO_CLIENT_ID,

    // Expuesto al cliente
    public: {
      apiBase: process.env.API_BASE_URL,
      siteUrl: process.env.SITE_URL || 'http://localhost:3000',
    },
  },

  app: {
    head: {
      htmlAttrs: { lang: 'es' },
      meta: [
        { charset: 'utf-8' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      ],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

El preset: 'aws-lambda' y streaming: true son toda la config que necesitas. Nitro genera el handler apropiado.

Estructura del proyecto

server/
  api/
    posts.get.ts          # GET /api/posts
    posts/[id].get.ts     # GET /api/posts/:id
    posts.post.ts         # POST /api/posts
  middleware/
    auth.ts
    rate-limit.ts
  routes/
    sitemap.xml.ts
  utils/
    db.ts
    cognito.ts
pages/
  index.vue
  blog/[slug].vue
  admin/**/*.vue
components/
  ProductCard.vue
  ShopLayout.vue
composables/
  useAuth.ts
  useProducts.ts
Enter fullscreen mode Exit fullscreen mode

API routes tipadas

Una API route en Nuxt:

// server/api/posts.get.ts
import { z } from 'zod';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';

const QuerySchema = z.object({
  limit: z.coerce.number().min(1).max(100).default(20),
  cursor: z.string().optional(),
  tag: z.string().optional(),
});

const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export default defineEventHandler(async (event) => {
  const query = await getValidatedQuery(event, QuerySchema.parse);
  const config = useRuntimeConfig();

  const result = await dynamo.send(
    new QueryCommand({
      TableName: config.dynamoTable,
      IndexName: 'published-index',
      KeyConditionExpression: 'published = :p',
      FilterExpression: query.tag ? 'contains(tags, :tag)' : undefined,
      ExpressionAttributeValues: {
        ':p': 1,
        ...(query.tag && { ':tag': query.tag }),
      },
      Limit: query.limit,
      ExclusiveStartKey: query.cursor
        ? JSON.parse(Buffer.from(query.cursor, 'base64').toString())
        : undefined,
      ScanIndexForward: false,
    })
  );

  return {
    items: result.Items ?? [],
    nextCursor: result.LastEvaluatedKey
      ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
      : null,
  };
});
Enter fullscreen mode Exit fullscreen mode

Middleware de auth

// server/middleware/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(`https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}/.well-known/jwks.json`)
);

const PROTECTED_PATHS = ['/api/admin', '/api/posts'];

export default defineEventHandler(async (event) => {
  const url = getRequestURL(event);

  const needsAuth = PROTECTED_PATHS.some(p => url.pathname.startsWith(p));
  if (!needsAuth) return;

  // POST, PUT, DELETE requieren auth. GET opcional.
  if (event.method === 'GET' && !url.pathname.startsWith('/api/admin')) {
    return;
  }

  const authHeader = getHeader(event, 'authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized',
    });
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`,
      audience: process.env.COGNITO_CLIENT_ID,
    });

    event.context.user = {
      id: payload.sub as string,
      email: payload.email as string,
      groups: (payload['cognito:groups'] as string[]) ?? [],
    };
  } catch {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid token',
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Composables tipados

// composables/usePosts.ts
interface Post {
  id: string;
  title: string;
  slug: string;
  content: string;
  publishedAt: string;
  tags: string[];
}

interface PostsResponse {
  items: Post[];
  nextCursor: string | null;
}

export function usePosts(options: { tag?: Ref<string>; limit?: number } = {}) {
  const { data, pending, error, refresh } = useFetch<PostsResponse>('/api/posts', {
    query: computed(() => ({
      tag: options.tag?.value,
      limit: options.limit ?? 20,
    })),
    watch: [options.tag],
    default: () => ({ items: [], nextCursor: null }),
  });

  return {
    posts: computed(() => data.value?.items ?? []),
    hasMore: computed(() => !!data.value?.nextCursor),
    isLoading: pending,
    error,
    refresh,
  };
}
Enter fullscreen mode Exit fullscreen mode

Páginas Vue con SSR

<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const slug = computed(() => route.params.slug as string);

const { data: post, error } = await useFetch(`/api/posts/${slug.value}`, {
  key: `post-${slug.value}`,
});

if (error.value || !post.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post not found',
    fatal: true,
  });
}

useSeoMeta({
  title: post.value.title,
  description: post.value.excerpt,
  ogTitle: post.value.title,
  ogDescription: post.value.excerpt,
  ogImage: post.value.coverImage,
  twitterCard: 'summary_large_image',
});
</script>

<template>
  <article>
    <header>
      <h1>{{ post.title }}</h1>
      <time :datetime="post.publishedAt">
        {{ new Date(post.publishedAt).toLocaleDateString('es-CO') }}
      </time>
    </header>

    <div v-html="post.content" />

    <LazyCommentsSection :post-id="post.id" />
  </article>
</template>
Enter fullscreen mode Exit fullscreen mode

Terraform para el deploy

Para equipos que no usan CDK, Terraform hace el mismo trabajo:

# infra/main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "terraform-state-miempresa"
    key            = "nuxt-app/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

provider "aws" {
  region = var.region
}

variable "region" {
  default = "us-east-1"
}

variable "project" {
  default = "nuxt-app"
}

variable "environment" {
  default = "production"
}

# S3 bucket para assets estáticos
resource "aws_s3_bucket" "static" {
  bucket = "${var.project}-${var.environment}-static"

  tags = {
    Project     = var.project
    Environment = var.environment
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
  bucket = aws_s3_bucket.static.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "static" {
  bucket = aws_s3_bucket.static.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Lambda function para SSR
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../.output/server"
  output_path = "${path.module}/lambda.zip"
}

resource "aws_iam_role" "lambda_role" {
  name = "${var.project}-${var.environment}-lambda"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamo" {
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = [
        "dynamodb:Query",
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
      ]
      Effect   = "Allow"
      Resource = aws_dynamodb_table.posts.arn
    }, {
      Action   = ["dynamodb:Query"]
      Effect   = "Allow"
      Resource = "${aws_dynamodb_table.posts.arn}/index/*"
    }]
  })
}

resource "aws_lambda_function" "ssr" {
  function_name    = "${var.project}-${var.environment}-ssr"
  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  handler          = "index.handler"
  runtime          = "nodejs20.x"
  memory_size      = 1024
  timeout          = 30
  architectures    = ["arm64"]
  role             = aws_iam_role.lambda_role.arn

  environment {
    variables = {
      NODE_ENV            = "production"
      DYNAMO_TABLE        = aws_dynamodb_table.posts.name
      COGNITO_USER_POOL_ID = aws_cognito_user_pool.main.id
      COGNITO_CLIENT_ID   = aws_cognito_user_pool_client.main.id
      AWS_REGION_NAME     = var.region
    }
  }
}

resource "aws_lambda_function_url" "ssr" {
  function_name      = aws_lambda_function.ssr.function_name
  authorization_type = "NONE"
  invoke_mode        = "RESPONSE_STREAM"

  cors {
    allow_credentials = true
    allow_origins     = ["*"]
    allow_methods     = ["*"]
    allow_headers     = ["*"]
    max_age           = 86400
  }
}

# CloudFront
resource "aws_cloudfront_origin_access_control" "s3" {
  name                              = "${var.project}-${var.environment}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "main" {
  enabled             = true
  default_root_object = "index.html"
  price_class         = "PriceClass_100"
  http_version        = "http2and3"

  origin {
    domain_name              = aws_s3_bucket.static.bucket_regional_domain_name
    origin_id                = "s3"
    origin_access_control_id = aws_cloudfront_origin_access_control.s3.id
  }

  origin {
    domain_name = replace(aws_lambda_function_url.ssr.function_url, "/^https?://([^/]+).*/", "$1")
    origin_id   = "lambda"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    target_origin_id       = "lambda"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true

    cache_policy_id          = aws_cloudfront_cache_policy.ssr.id
    origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer_except_host.id
  }

  ordered_cache_behavior {
    path_pattern           = "/_nuxt/*"
    target_origin_id       = "s3"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true

    cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

resource "aws_cloudfront_cache_policy" "ssr" {
  name        = "${var.project}-ssr"
  default_ttl = 0
  min_ttl     = 0
  max_ttl     = 86400

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_gzip   = true
    enable_accept_encoding_brotli = true

    cookies_config {
      cookie_behavior = "none"
    }
    headers_config {
      header_behavior = "whitelist"
      headers {
        items = ["Accept-Language", "CloudFront-Viewer-Country"]
      }
    }
    query_strings_config {
      query_string_behavior = "all"
    }
  }
}

data "aws_cloudfront_cache_policy" "caching_optimized" {
  name = "Managed-CachingOptimized"
}

data "aws_cloudfront_origin_request_policy" "all_viewer_except_host" {
  name = "Managed-AllViewerExceptHostHeader"
}

# Output
output "cloudfront_url" {
  value = aws_cloudfront_distribution.main.domain_name
}
Enter fullscreen mode Exit fullscreen mode

DynamoDB table

resource "aws_dynamodb_table" "posts" {
  name         = "${var.project}-posts"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }

  attribute {
    name = "slug"
    type = "S"
  }

  attribute {
    name = "published"
    type = "N"
  }

  attribute {
    name = "publishedAt"
    type = "S"
  }

  global_secondary_index {
    name            = "by-slug"
    hash_key        = "slug"
    projection_type = "ALL"
  }

  global_secondary_index {
    name            = "published-index"
    hash_key        = "published"
    range_key       = "publishedAt"
    projection_type = "ALL"
  }

  point_in_time_recovery {
    enabled = true
  }

  server_side_encryption {
    enabled = true
  }

  tags = {
    Project     = var.project
    Environment = var.environment
  }
}
Enter fullscreen mode Exit fullscreen mode

Pipeline de deploy

# .github/workflows/deploy.yml
name: Deploy Nuxt

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v3
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build Nuxt
        run: pnpm run build

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployer
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Terraform Init
        run: cd infra && terraform init

      - name: Terraform Plan
        run: cd infra && terraform plan -out=tfplan

      - name: Terraform Apply
        run: cd infra && terraform apply tfplan

      - name: Upload static assets
        run: |
          aws s3 sync .output/public s3://${{ secrets.STATIC_BUCKET }}/ \
            --cache-control "public,max-age=31536000,immutable" \
            --exclude "*.html"

          aws s3 sync .output/public s3://${{ secrets.STATIC_BUCKET }}/ \
            --cache-control "public,max-age=300" \
            --exclude "*" \
            --include "*.html"

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.DISTRIBUTION_ID }} \
            --paths "/*"
Enter fullscreen mode Exit fullscreen mode

Métricas y observabilidad

Nuxt tiene un hook para registrar duración de rutas:

// server/plugins/monitoring.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', (event) => {
    event.context.startTime = Date.now();
  });

  nitroApp.hooks.hook('afterResponse', (event, { body }) => {
    const duration = Date.now() - event.context.startTime;

    // Enviar a CloudWatch custom metrics
    console.log(JSON.stringify({
      _aws: {
        Timestamp: Date.now(),
        CloudWatchMetrics: [{
          Namespace: 'NuxtApp',
          Dimensions: [['Route']],
          Metrics: [{ Name: 'ResponseTime', Unit: 'Milliseconds' }],
        }],
      },
      Route: event.path,
      ResponseTime: duration,
    }));
  });
});
Enter fullscreen mode Exit fullscreen mode

Lo que aprendí con Nuxt en Lambda

1. El build de Nitro es rapidísimo.

Comparado con Next.js standalone, Nitro toma ~30% del tiempo. Los deploys son mucho más rápidos.

2. ISR funciona bien pero no es mágico.

Los archivos cacheados se guardan en /tmp de Lambda. Cuando el container muere, se pierden. Para ISR real, guarda en S3.

3. Las API routes son productivas.

No necesitas salir de Nuxt para tener un backend. Muchos proyectos no requieren una API separada.

4. Terraform es más explícito que CDK.

Para equipos que vienen de DevOps tradicional, Terraform se siente natural. Para equipos frontend, CDK es más cercano.

5. routeRules es oro.

Mezclar páginas prerenderizadas, SSR e ISR en una sola config es un diferencial real.

Cierre

Nuxt 3 en AWS Lambda con Terraform es una combinación madura. La DX es excelente, los deploys son rápidos, y tienes control total. Para equipos Vue, es el camino natural a AWS sin pasar por Amplify.

En el próximo: SvelteKit con SST, otro stack que está ganando tracción en 2025.

Top comments (0)