iTranslated by AI
Deploying Next.js to AWS with SST — The Full Package: CloudFront, IP Restriction, and Cron
Introduction
My personal project's diary app Storyie is built with Next.js. It is deployed on AWS. I use SST (Serverless Stack) to define the infrastructure.
I can hear people saying "Why not just use Vercel?", but there are situations where you want the flexibility of AWS precisely because it's a personal project. CloudFront cache control, IP restrictions for the staging environment, Lambda cron jobs. For things where Vercel's customization is limited (or expensive), SST allows you to write them declaratively in TypeScript.
In this article, based on the actual sst.config.ts, I will write about the design decisions and pitfalls when deploying Next.js with SST.
What is SST?
SST is a framework for deploying full-stack apps on AWS. Internally, it is based on Pulumi and allows you to define infrastructure using TypeScript.
For Next.js, the sst.aws.Nextjs component is provided, which automatically assembles a configuration of Lambda + CloudFront + S3 via OpenNext.
new sst.aws.Nextjs("StoryieWeb", {
path: "./apps/web",
domain: {
name: "storyie.com",
aliases: ["*.storyie.com"],
dns: sst.cloudflare.dns(),
},
});
With just this, a CloudFront distribution, Lambda functions, and an S3 bucket are created. Since I am using Cloudflare for DNS, I specify sst.cloudflare.dns().
The Concept of Stages
One of the powerful features of SST is stages. You can create completely independent environments with sst deploy --stage production and sst deploy --stage dev.
app: async (input) => {
const stage = input?.stage || "dev";
const envFile = stage === "production" ? ".env.production" : ".env";
// ...
return {
name: "storyie",
removal: input?.stage === "production" ? "retain" : "remove",
home: "aws",
};
},
Points:
-
removal: "retain": Keep resources even when the stack is deleted in the production environment (Safety valve) -
Switching
.envfiles: Change the environment variable file to load according to the stage -
Domain branching:
storyie.comfor production,staging.storyie.comfor staging
const domain =
stage === "production"
? {
name: "storyie.com",
aliases: ["*.storyie.com"],
dns: sst.cloudflare.dns(),
}
: {
name: "staging.storyie.com",
aliases: ["*.staging.storyie.com"],
dns: sst.cloudflare.dns(),
};
Since Storyie is multi-tenant ({username}.storyie.com), a wildcard domain is required. I reproduce the same structure in staging with *.staging.storyie.com.
IP Restrictions with CloudFront Functions
I have set up IP restrictions for the staging environment. Using CloudFront Functions, I return a 403 error for access from unauthorized IPs.
const ipRestrictionCode =
stage !== "production"
? `
var allowedIPs = ["221.246.xxx.xxx", "153.166.xxx.xxx"];
var clientIP = event.viewer.ip;
var uri = event.request.uri;
// Bypass webhook paths as they are accessed from external services
var bypassPaths = ["/api/stripe/webhook"];
var shouldBypass = bypassPaths.some(function(path) {
return uri === path || uri.startsWith(path + "?");
});
if (!shouldBypass && allowedIPs.indexOf(clientIP) === -1) {
return {
statusCode: 403,
statusDescription: "Forbidden",
headers: { "content-type": { value: "text/html" } },
body: "Access denied.",
};
}
`.trim()
: undefined;
Key Pitfalls:
-
CloudFront Functions are equivalent to ES5. Features like
includes()orconstcannot be used. You need to useindexOfandvar. - Bypassing the webhook path is essential. Stripe webhooks come from Stripe's servers, so they will be blocked if you apply IP restrictions without an exception.
-
CloudFront Functions execute on the
viewer-requestevent. They are more lightweight and cost-effective than Lambda@Edge.
By passing the code as a string to SST's edge.viewerRequest.injection, it gets injected into the CloudFront Function.
edge: ipRestrictionCode
? {
viewerRequest: {
injection: ipRestrictionCode,
},
}
: undefined,
Disabling Cache for OAuth Routes
Authentication callbacks (/api/auth/*) must never be cached. OAuth authorization codes are one-time use, and caching them will break the authentication process.
const cachingDisabledPolicy = await aws.cloudfront.getCachePolicy({
name: "Managed-CachingDisabled",
});
// Disable caching for /api/auth/* using CloudFront's ordered cache behavior
const authCacheBehavior = {
pathPattern: "/api/auth/*",
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
cachedMethods: ["GET", "HEAD"],
cachePolicyId: cachingDisabledPolicy.id,
compress: true,
};
I'm using SST's transform.cdn to customize the CloudFront distribution settings. The trick is to use $resolve to handle Pulumi's Input types while inheriting properties from the default cache behavior.
transform: {
cdn: (args) => {
args.orderedCacheBehaviors = $resolve([
args.orderedCacheBehaviors,
args.defaultCacheBehavior,
]).apply(([existing, defaultBehavior]) => {
const existingBehaviors = Array.isArray(existing) ? existing : [];
return [
{
...authCacheBehavior,
targetOriginId: defaultBehavior.targetOriginId,
originRequestPolicyId: defaultBehavior.originRequestPolicyId,
functionAssociations: defaultBehavior.functionAssociations,
},
...existingBehaviors,
];
});
},
},
Optimization of Lambda Functions
server: {
memory: "1024 MB",
runtime: "nodejs22.x",
architecture: "arm64",
timeout: "20 seconds",
},
imageOptimization: {
memory: "1536 MB",
},
-
arm64: Graviton is about 20% cheaper than x86, with equal or better performance. -
nodejs22.x: The latest LTS runtime. -
Image optimization at 1536 MB: The resizing process in Next.js's
<Image>component consumes surprisingly much memory.
Cron Jobs
Using SST's sst.aws.Cron, I run periodic batch processes on Lambda. The following cron jobs are running in Storyie:
| Job | Schedule | Usage |
|---|---|---|
| PerformanceAggregator | Daily 2:00 UTC | Aggregation of performance metrics |
| ViewAggregator | Every 4 hours :00 | Aggregation of view counts |
| LikeAggregator | Every 4 hours :30 | Aggregation of like counts |
| TagManager | Hourly | Tag extraction and synchronization from diaries |
| DiaryReminderNotifier | Every 15 minutes | Diary reminder notifications |
| WeeklySummaryEmailSender | Every Sunday 12:00 UTC | Weekly summary emails |
| MilestoneEmailSender | Every 4 hours | Milestone detection and emails |
Similar jobs are offset to distribute the DB load. By running View at :00 and Like at :30, I avoid simultaneous queries.
// View: Every 4 hours at :00
new sst.aws.Cron("ViewAggregator", {
schedule: "cron(0 */4 * * ? *)",
// ...
});
// Like: Every 4 hours at :30 (30-minute offset)
new sst.aws.Cron("LikeAggregator", {
schedule: "cron(30 */4 * * ? *)",
// ...
});
Management of Environment Variables
Environment variables are passed to Lambda using SST's environment property. Here is one important point to note.
// Explicitly set the URL according to the stage (overwrites .env values)
NEXT_PUBLIC_BASE_URL:
stage === "production"
? "https://storyie.com"
: "https://staging.storyie.com",
Even if NEXT_PUBLIC_BASE_URL=https://storyie.com is written in .env.production, it is overwritten in SST's environment. The principle is to centralize stage branching in the infrastructure code. If you manage it doubly in .env files and infrastructure code, they will eventually drift apart.
Caching Strategy
invalidation: {
paths: "all",
wait: false,
},
assets: {
nonVersionedFilesCacheHeader:
"public,max-age=0,s-maxage=86400,stale-while-revalidate=8640",
versionedFilesCacheHeader:
"public,max-age=31536000,immutable",
},
-
Versioned files (under
_next/static/) are cached for 1 year + immutable. - Non-versioned files have no browser cache, while the CDN has a 1-day cache + SWR.
-
Invalidating all paths upon deployment immediately purges old caches. Setting
wait: falseallows cache invalidation to run in the background without waiting for the deployment to finish.
Comparison with Vercel
To be honest, Vercel is easier for simple Next.js deployments. You can deploy just by doing a git push, and preview environments are created automatically.
The reasons for choosing SST are:
- Fine-grained control over CloudFront (IP restrictions, cache policies, edge functions).
- Lambda cron jobs can be managed within the same codebase.
- Cost. Vercel Pro is $20/month per member. AWS charges only for what you use.
- Full control over multi-tenant domain settings.
- Avoiding lock-in. AWS is a general-purpose infrastructure, providing many migration options.
Conversely, the disadvantages of SST:
- Initial setup is heavy. Creating a CloudFormation stack takes 10-15 minutes.
- Deployment is slow. Compared to Vercel's dozens of seconds, SST takes 2-5 minutes.
- OpenNext compatibility. New Next.js features might not be available immediately.
- Debugging is painful. You need to go check CloudWatch Logs.
Summary
I have introduced the configuration for deploying Next.js with SST. Key points:
- Complete separation of environments by stage. Generate different infrastructure from the same code for production and staging.
- IP restrictions with CloudFront Functions. Beware of ES5 limitations and bypass paths.
- Disabling cache for OAuth routes. Ensuring the one-time nature of authentication codes.
- Cost optimization with Graviton (arm64).
- Load balancing for cron jobs with offsets.
- Centralized management of environment variables in infrastructure code.
The decision to use AWS for a personal project might look like over-engineering. However, rather than hitting "Vercel's limitations" and migrating as the service grows, it is ultimately easier to get used to AWS from the start. SST is a great tool that acts as that bridge.
Discussion