Portfolio

Back to Blog

Next.js deployment

Enterprise Next.js deployment with SST, WAF security, and multi-tenant routing

TypeScriptNext.jsSSTWAF
🌐 Multi-tenant architecture

Deploying Enterprise Next.js to AWS: WAF Security, Multi-Tenant Routing, and SST

Subtitle: How to Deploy Production-Grade Next.js Apps with IP Whitelisting, Custom Domains, and Zero-Infrastructure Management

By David Kwon, Full-Stack Engineer & Cloud Architect
Estimated read time: 7-9 min

Note: Company-specific details have been anonymized. "FoodCo" is used as a placeholder, along with generic domain names, to maintain confidentiality while preserving the technical integrity of this case study.

Table of Contents


Introduction: The Problem Hook

Gone are the days when you could push a Next.js app to Vercel and call it production-ready. Enterprise deployments require VPC integration, IP whitelisting for security, multi-environment configuration, SSO integration with corporate identity providers, and compliance with data residency requirements—all without the operational burden of managing EC2 instances or Kubernetes clusters.

Here's the challenge: You need to deploy a pricing management application for an enterprise restaurant chain. The app must be accessible only through the corporate CDN, integrate with AWS Cognito for SSO, support multiple environments (dev, QA, preprod, prod) with different domains, and handle multi-tenant routing where production lives at /pricing-app path while dev environments use root paths. Oh, and it needs to be serverless with auto-scaling, zero downtime deployments, and cost less than $50/month for compute.

Traditional approaches fall short. Vercel/Netlify: No control over network-level security, can't integrate with corporate VPCs, expensive at enterprise scale. Self-hosted on EC2: Operational overhead of managing servers, auto-scaling groups, load balancers, and zero-downtime deployments. ECS/Kubernetes: Massive overkill for a Next.js app, requires dedicated DevOps team.

This is what I built with the Pricing Frontend: a production-grade Next.js application deployed with SST (Serverless Stack) that achieves enterprise security requirements while maintaining serverless simplicity and developer ergonomics.

We'll explore WAF configuration patterns, SST deployment strategies, and multi-environment architecture that scales from local development to multi-region production.

flowchart TD
    A[Developer] --> B[SST Dev]
    B --> C[Lambda + CloudFront]
    C --> D[WAF IP Whitelist]
    D --> E[Corporate CDN]
    E --> F[End User]

The Problem in Depth

Summary: Enterprise needs exceed simple hosting.

Deploying Next.js at enterprise scale introduces several architectural and security challenges:

1. Security Compliance: Corporate security requires all external traffic to flow through an approved enterprise CDN. Direct access to application origins must be blocked. This requires AWS WAF with IP whitelisting—but Next.js on Lambda behind CloudFront doesn't support WAF natively without custom configuration.

2. Multi-Tenant Path Routing: Different environments have different base paths. Production runs at https://api.company.com/pricing-app/ (shared domain with other apps), while dev runs at https://dev-www.companypricing.com/ (dedicated domain, root path). NextAuth, API routes, and static assets must all respect environment-specific base paths without code changes.

3. Authentication Complexity: AWS Cognito integration with corporate SSO requires precise configuration: callback URLs, logout URLs, token validation, and session management. NextAuth configuration must adapt to different Cognito pools per environment without hardcoding credentials.

4. Zero-Downtime Deployments: With 100+ internal users actively managing prices, deployments must be invisible. CloudFront cache invalidation, Lambda@Edge updates, and DNS cutover must be orchestrated atomically without downtime or stale cache issues.

Traditional approaches fall short. Manual CloudFormation: 500+ lines of YAML, error-prone, slow iterations. Terraform: Better, but still verbose and requires infrastructure expertise. AWS Amplify: Opinionated, limited WAF control, doesn't support custom base paths. CDK for Next.js: Requires writing Lambda@Edge functions, CloudFront distributions, S3 origins—reinventing the wheel.

HostingWAF ControlCustom PathsDXCost
VercelHigh
AmplifyMedium
SSTLow

Solution Patterns

Summary: SST + WAF enable enterprise-ready deploys.

Pattern 1: SST (Serverless Stack) for Infrastructure-as-Code

Rather than manually wiring CloudFront, Lambda, S3, and IAM policies, the Pricing Frontend uses SST's NextjsSite construct which handles all infrastructure with sensible defaults while remaining fully customizable.

How it works:

const site = new NextjsSite(stack, "site", {
  runtime: "nodejs20.x",
  customDomain: domainConfig,
  timeout: "1 minute",
  environment: {
    APP_STAGE: stage,
    NEXT_PUBLIC_BASE_PATH: getBasePath(stage),
    NEXTAUTH_SECRET: nextAuthSecret.secretValue.toString(),
    NEXTAUTH_URL: getNextAuthUrl(stage),
    AUTH_CLIENT_ID: authClientId.stringValue,
    AUTH_POOL_ID: authPoolId.stringValue,
    GRAPHQL_ENDPOINT: GRAPHQL_ENDPOINT[stage],
  },
  cdk: {
    distribution: {
      webAclId: waf?.attrArn, // Attach WAF to CloudFront
    },
  },
});

site.attachPermissions([
  "ssm:GetParameters",
  "dynamodb:Query",
  "dynamodb:PutItem",
  "dynamodb:GetItem",
]);

Behind the scenes, SST creates:

  • Lambda function for Next.js server-side rendering
  • CloudFront distribution with edge caching
  • S3 bucket for static assets
  • Lambda@Edge for routing and header manipulation
  • IAM roles with least-privilege permissions

💡 Pros: Type-safe IaC, local dev with hot reload.

Cons:

  • Framework lock-in: Migrating to raw CDK requires rewriting infrastructure
  • Black box abstractions: Debugging SST internals requires reading source code
  • Beta features: Some SST features are alpha/beta (though stable in practice)

Outcome: Infrastructure deployment time: 5 minutes (initial), 2 minutes (updates). Developer setup: pnpm install && sst dev (no AWS console clicking required).

Pattern 2: AWS WAF with CDN IP Whitelisting

To enforce traffic through corporate CDN, the application uses AWS WAF attached to CloudFront with IP set whitelisting for approved CDN edge servers.

How it works:

// Create IP set with CDN edge server IPs
const cdnIpSet = new CfnIPSet(stack, "CdnIpSet", {
  addresses: [
    "10.0.0.0/24",
    "10.1.0.0/24",
    "10.2.0.0/24",
    "10.3.0.0/24",
    // ... 15+ CDN IP ranges (example IPs shown)
  ],
  ipAddressVersion: "IPV4",
  scope: "CLOUDFRONT", // Must be CLOUDFRONT for CloudFront distributions
});

// Create WAF with default BLOCK, allow only approved CDN
const waf = new CfnWebACL(stack, "PricingAppWaf", {
  name: `company-${stage}-pricing-app-waf`,
  defaultAction: {
    block: {}, // Block all traffic by default
  },
  scope: "CLOUDFRONT",
  rules: [
    {
      name: "AllowCdnIps",
      action: {
        allow: {}, // Only allow CDN IPs
      },
      priority: 0,
      statement: {
        ipSetReferenceStatement: {
          arn: cdnIpSet.attrArn,
        },
      },
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "AllowsToCdnIpSet",
        sampledRequestsEnabled: true,
      },
    },
  ],
  visibilityConfig: {
    cloudWatchMetricsEnabled: true,
    metricName: "DefaultBlocks",
    sampledRequestsEnabled: true,
  },
});

// Attach WAF to CloudFront distribution
const site = new NextjsSite(stack, "site", {
  cdk: {
    distribution: {
      webAclId: waf.attrArn,
    },
  },
});

💡 Pros: Transparent enforcement, audit trails.

Cons:

  • IP maintenance: CDN provider IPs can change (requires monitoring and updates)
  • Regional scope: WAF for CloudFront must be in us-east-1 (AWS limitation)
  • Cost: ~$5/month base + $1 per million requests

Real-world outcome: Zero unauthorized access incidents. Internal security audit passed with no findings. Direct CloudFront URL access returns 403 Forbidden, forcing all traffic through approved CDN.

Pattern 3: Environment-Specific Configuration with Type Safety

Rather than hardcoding environment variables or using brittle .env files, the application uses typed configuration maps with compile-time validation.

How it works:

// Domain configuration per environment
const DOMAIN_CONFIG_MAP: Record<string, SsrDomainProps> = {
  dev: {
    domainName: "dev-www.companypricing.com",
    domainAlias: "www.dev-www.companypricing.com",
    hostedZone: "dev-www.companypricing.com",
  },
  qa: {
    domainName: "qa-www.companypricing.com",
    domainAlias: "www.qa-www.companypricing.com",
    hostedZone: "qa-www.companypricing.com",
  },
  preprod: {
    domainName: "api-stage.company.com",
    alternateNames: ["api-stage.company.com"],
    hostedZone: "company.com",
  },
  prod: {
    domainName: "api.company.com",
    alternateNames: ["api.company.com"],
    hostedZone: "company.com",
  },
};

// GraphQL endpoints per environment
const GRAPHQL_ENDPOINT: Record<string, string> = {
  dev: "https://dev.api.company-menu.com/graphql",
  qa: "https://qa.api.company-menu.com/graphql",
  preprod: "https://api-stage.company.com/menu/graphql",
  prod: "https://api.company.com/menu/graphql",
};

// Base path routing per environment
function getBasePath(stage: string): string {
  switch (stage) {
    case "preprod":
    case "prod":
      return "/pricing-app"; // Multi-tenant: shared domain
    default:
      return ""; // Dedicated domain: root path
  }
}

// NextAuth URL generation (respects base path)
function getNextAuthUrl(stage: string): string {
  const domainConfig = DOMAIN_CONFIG_MAP[stage];
  const alternateNames = domainConfig.alternateNames;

  if (alternateNames && alternateNames.length > 0) {
    return `https://${alternateNames[0]}${getNextAuthBasePath(stage)}`;
  }

  return `https://${domainConfig.domainName}`;
}

💡 Pros: Compile-time safety, single truth source.

Cons:

  • Not runtime-configurable: Changes require redeployment
  • Secret management: Secrets still need SSM Parameter Store / Secrets Manager
  • Duplication: Some config duplicated between SST and Next.js runtime

Real-world outcome: Zero configuration errors in production. New environments (e.g., staging2) added in 10 minutes by copy-pasting configuration block.

Pattern 4: Custom Domain with Alternate Names (Multi-Tenant)

Production uses a multi-tenant domain where multiple applications share api.company.com with different base paths (/pricing-app, /menu, /orders). This requires custom certificate management.

How it works:

let domainConfig = DOMAIN_CONFIG_MAP[stage];

// For multi-tenant (alternate names), create custom certificate
if (domainConfig && domainConfig.alternateNames) {
  domainConfig = {
    ...domainConfig,
    cdk: {
      certificate: new Certificate(stack, "Certificate", {
        domainName: domainConfig.domainName,
        subjectAlternativeNames: domainConfig.alternateNames, // SAN: api.company.com
        validation: CertificateValidation.fromDns(), // DNS validation via Route53
      }),
    },
  };
}

const site = new NextjsSite(stack, "site", {
  customDomain: domainConfig,
  environment: {
    NEXT_PUBLIC_BASE_PATH: getBasePath(stage), // /pricing-app
    NEXTAUTH_BASE_PATH: getNextAuthBasePath(stage), // /pricing-app/api/auth
  },
});

💡 Pros: Consolidated DNS, cost savings.

Cons:

  • Certificate coordination: Multiple teams must coordinate SAN certificate updates
  • Routing complexity: Base path must be configured correctly in app and CDN
  • Deployment coupling: Certificate updates affect all apps on the domain

Outcome: Production serves at https://api.company.com/pricing-app/ with automatic HTTPS, CDN caching, and zero certificate management overhead (Route53 handles DNS validation).


Implementation Example

In the Pricing Frontend, we combined all four patterns to deploy a production-grade Next.js application with enterprise security, multi-environment support, and serverless cost efficiency.

Tech Stack

  • Next.js 14: App Router with server components
  • SST (Ion): Infrastructure-as-Code for Next.js
  • AWS CloudFront: CDN with edge caching
  • AWS Lambda: Serverless compute for SSR
  • AWS WAF: IP whitelisting and layer 7 protection
  • NextAuth: Authentication with AWS Cognito
  • Material-UI: Component library
  • React Query: Server state management

Key Decision: Why SST over Amplify or Self-Managed?

We initially evaluated three approaches:

AWS Amplify Hosting:

  • ❌ Limited WAF control (can't attach custom WAF to Amplify)
  • ❌ No support for custom base paths
  • ❌ Opinionated CI/CD (requires GitHub integration)
  • ✅ Simplest deployment (git push)

Self-managed (CDK + Lambda + CloudFront):

  • ❌ 500+ lines of infrastructure code
  • ❌ Requires deep AWS knowledge
  • ❌ Manual cache invalidation logic
  • ✅ Full control

SST (Serverless Stack):

  • ✅ 100 lines of infrastructure code
  • ✅ Best practices baked in
  • ✅ Full CDK access for customization (WAF, certificates)
  • ✅ Excellent DX (sst dev with live Lambda logs)
  • ⚠️ Framework dependency

We chose SST for the 80/20 sweet spot: simple for common cases, customizable for edge cases.

Outcome: Real Numbers

After deploying to production with SST:

  • Deployment time: 2-5 minutes (vs. 15+ minutes with manual CDK)
  • Monthly cost: $35 (Lambda invocations + CloudFront + WAF)
  • P95 TTFB: 180ms (server-side rendering)
  • P95 static assets: 12ms (CloudFront edge cache)
  • Availability: 99.99% (no downtime in 6 months)
  • Security incidents: 0 (WAF blocks all non-approved traffic)

Here's the actual middleware configuration for NextAuth:

import { withAuth } from "next-auth/middleware";

export default withAuth({
  pages: {
    signIn: "/login", // Redirect unauthenticated users
  },
});

// Protect all routes except public assets
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

And the NextAuth configuration respecting base paths:

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession();
  const BASE_PATH = process.env["NEXTAUTH_BASE_PATH"];

  return (
    <SessionProvider session={session} basePath={BASE_PATH}>
      <ThemeProvider theme={theme}>
        <Header />
        <Container>{children}</Container>
        <Footer />
      </ThemeProvider>
    </SessionProvider>
  );
}
{
  "type": "bar",
  "data": {
    "labels": ["Deployment Time (min)", "Cost/Month", "TTFB (ms)", "Availability (%)", "Incidents"],
    "datasets": [{
      "label": "Metrics",
      "data": [3, 35, 180, 99.99, 0],
      "backgroundColor": "#2ecc71"
    }]
  },
  "options": {
    "plugins": {"title": {"display": true, "text": "Deployment Outcomes"}}
  }
}

Lessons Learned

Summary: Monitor IPs and test paths rigorously.

1. WAF IP Sets Need Monitoring

CDN providers occasionally add new edge server IPs. We initially had 10 IPs; after 3 months, we needed 17. Without monitoring, new CDN servers would be blocked. Takeaway: Set up CloudWatch alarms for WAF block rate spikes—indicator of missing IPs.

2. Base Path Configuration is Tricky

NextAuth, MUI components, and API routes all need base path configuration. Missing even one breaks production. We now have integration tests that verify all URLs respect NEXT_PUBLIC_BASE_PATH. Takeaway: Test base path routing in CI, not just locally.

3. SST Dev Mode is Production-Adjacent

sst dev runs Lambda functions locally but uses real AWS resources (DynamoDB, S3, SSM). This caught 5 configuration errors before deployment. Takeaway: Always test with sst dev before sst deploy—it uses actual AWS state.

4. Certificate Validation Takes Time

DNS-validated certificates take 5-10 minutes to issue on first deploy. This blocked our CI/CD initially. Takeaway: Pre-create certificates in shared stack, reference them in app stack.


Takeaways for Developers

Use SST when:

  • Deploying Next.js, Remix, Astro, or SvelteKit to AWS
  • You want infrastructure-as-code without boilerplate
  • Team has TypeScript skills, limited AWS CDK experience
  • You need customization beyond Vercel/Netlify

Use AWS WAF when:

  • Security requires IP whitelisting (CDN, VPN, corporate networks)
  • You need layer 7 filtering (geographic blocking, rate limiting)
  • Compliance requires audit trails for blocked requests
  • Budget allows ~$5-20/month for WAF

Use multi-tenant domains when:

  • Multiple apps share corporate domain (e.g., api.company.com)
  • You want to consolidate DNS zones
  • Certificate management is coordinated across teams
  • Base path routing is acceptable (no root path required)

Avoid SST when:

  • Using non-JavaScript stacks (Go, Python, Rust)
  • Requirements are purely serverless functions (use Serverless Framework)
  • Team prefers pure Terraform (SST uses CDK under the hood)
  • You need multi-cloud (SST is AWS-only)

What's Your Experience with Next.js on AWS?

Have you deployed Next.js to AWS? What patterns worked for security, multi-environment config, or custom domains? How do you balance developer experience with infrastructure control? I'd love to hear your experiences—reach out via LinkedIn or explore more architecture patterns on my portfolio.


Explore More Projects: Pricing Service | Menu Config Service | Product Service | Diablo ETL Framework

GitHub: While this is proprietary enterprise code, I'm happy to discuss architecture decisions and patterns in detail.

Contact: david.kwon@example.com | LinkedIn | Portfolio