4

GimmetheCache
This site runs on AWS β containerized in Docker, deployed via ECS Fargate, served through CloudFront, with a multi-layer cache architecture that keeps every page and image fast across restarts and deployments. This post is a technical walkthrough of how that infrastructure works, the problems that came up while building it, and what each piece is actually doing.
Moving off Vercel
This site started on Vercel β which is excellent for most Next.js deployments. But Vercel's serverless model made one part of this site awkward: the Chatbot Mixer uses Vercel's Workflow Dev Kit, which runs durable long-lived executions on Vercel's infrastructure. That coupling, combined with wanting to own the full deployment pipeline, made self-hosting the right move.
The migration was done in two phases: move everything except the Chatbot Mixer first (Phase 1), then migrate the Chatbot Mixer separately once the main site was stable on AWS (Phase 2). Phase 1 took one session. The site now runs on AWS ECS Fargate behind an Application Load Balancer and CloudFront CDN β Sanity CMS is unchanged and doesn't care where the frontend lives.
Sanity CMS, all API routes, server actions, ISR, next/image optimization, Sanity Studio at /studio, middleware β all of this is standard Next.js and runs identically in a Docker container. The only Vercel-specific dependencies were the Workflow Dev Kit and @vercel/blob, both used exclusively by the Chatbot Mixer.
The Infrastructure
The app runs as a Docker container built with a three-stage Dockerfile: a deps stage that installs dependencies, a builder stage that runs next build, and a lean runner stage that copies only the standalone output β no source code, no dev dependencies, no build tools. The final image is around 150MB. next.config.js uses output: 'standalone' to produce a self-contained Node.js server.
# Stage 1: install deps
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Stage 2: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
ENV NEXT_PUBLIC_SANITY_PROJECT_ID=$NEXT_PUBLIC_SANITY_PROJECT_ID
RUN yarn build
# Stage 3: lean runtime image
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir -p /app/.next/cache && chown -R nextjs:nodejs /app/.next
USER nextjs
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
COPY /app/cache-handler.js ./cache-handler.js
CMD ["node", "server.js"]The container runs as a non-root user (nextjs, uid 1001) for security. If an attacker exploits a vulnerability in the app, they get a restricted account rather than the host machine. NEXT_PUBLIC_* vars are passed as build args β Next.js bakes them into the JS bundle at compile time, so they can't be injected at runtime.
AWS Stack
Images are pushed to Amazon ECR (Elastic Container Registry) and deployed on ECS Fargate (Express Mode) β managed containers without needing to provision or maintain EC2 instances. ECS Express Mode handles canary deploys by alternating traffic between two target groups, enabling zero-downtime deployments. An Application Load Balancer sits in front of ECS, and CloudFront sits in front of the ALB as a global CDN.
CloudFront connects to the ALB origin over HTTP (not HTTPS). If it tried to verify TLS against the ALB's internal hostname it would fail β the cert is issued for the public domain, not the ALB hostname. CloudFront handles TLS termination for the end user; the CloudFront-to-ALB leg is internal traffic on a private network.
CI/CD Pipeline
Every push to main triggers a GitHub Actions workflow that builds the Docker image for linux/amd64 (required because the build machine is Apple Silicon β AWS ECS runs on x86), pushes it to ECR with the commit SHA as a tag, registers a new ECS task definition revision, updates the service to the new revision, waits for the deployment to stabilize, and then syncs the HTTP listener weights on the ALB to match the HTTPS listener after ECS Express Mode performs its canary swap.
Push to live
Push to main
A git push to main triggers the GitHub Actions deploy workflow.
Docker build
GH Actions builds the image for linux/amd64 with --cache-from pointing at the previous ECR image. Code-only changes hit the yarn install cache layer and build in ~12 seconds.
Push to ECR
The new image is pushed to Amazon ECR tagged with the git commit SHA and :latest.
New task definition
A new ECS task definition revision is registered pointing at the new image.
ECS canary deploy
ECS Express Mode spins up a new task with the new image and gradually shifts traffic. The old task drains and shuts down. Zero downtime.
HTTP listener sync
The workflow reads the active target group weights from the HTTPS:443 listener and mirrors them to the HTTP:80 listener. CloudFront routes via HTTP β without this step, traffic goes to the old empty target group and the site 503s.
Cache warmer runs
warm-cache.mjs hits every page and image URL, populating Redis and EFS before real users arrive.
The Cache Stack
There are three cache layers, each solving a different problem at a different point in the request path. They're independent β CloudFront has no idea whether your server used Redis or EFS to build a response. It just caches the HTTP response it gets back.
1
Redis β Page Data Cache
Next.js's default ISR cache is in-memory and dies with the container β every redeploy cold-renders all pages. cache-handler.js replaces the default with Upstash Redis, so the ISR cache persists across deploys and container restarts. When a page is requested, the server checks Redis first. On a hit, it skips Sanity entirely and serves the cached HTML. TTL is set per page via the revalidate export β blog posts revalidate every 50 minutes, the blog index every 17 minutes.
// Next.js RSC payloads include Buffer objects (rscData).
// JSON.stringify converts them to { type:'Buffer', data:[...] }
// but they aren't revived to real Buffers on parse.
// Custom replacer/reviver so Buffers survive the Redis round-trip.
function toStorable(value) {
return JSON.parse(
JSON.stringify(value, (_k, v) =>
Buffer.isBuffer(v) ? { __b64__: v.toString('base64') } : v
)
);
}
function fromStorable(value) {
return JSON.parse(
JSON.stringify(value),
(_k, v) =>
v && typeof v === 'object' && typeof v.__b64__ === 'string'
? Buffer.from(v.__b64__, 'base64')
: v
);
}next.config.js sets cacheMaxMemorySize: 0 to disable Next.js's in-memory cache layer. Without this, Next.js maintains its own memory cache in front of the custom handler β reads and writes bypass Redis until the memory cache is full. Setting it to 0 forces all reads and writes through the Redis handler.
2
EFS β Image Optimization Cache
next/image optimizes images on first request β downloads the original from Sanity CDN, resizes it, converts to WebP or AVIF, and saves the result to .next/cache/images/. Subsequent requests serve the file directly. In a container, that cache lives on ephemeral disk and wipes on every deploy. An EFS (Elastic File System) volume is mounted at /app/.next/cache in the ECS task definition, so the processed image files persist across container restarts and redeployments.
EFS mounts its root directory as owned by root (uid 0). The container runs as the nextjs user (uid 1001). Without an EFS access point configured with posixUser: { uid: 1001, gid: 1001 }, every image cache write throws EACCES: permission denied. The Dockerfile chown doesn't help β EFS mounts over the directory at container startup, replacing its ownership. The access point makes EFS present the mount as owned by uid 1001 regardless of what's on the actual filesystem.
3
CloudFront β Edge Cache
CloudFront caches the full HTTP responses β page HTML and optimized image files β at AWS edge locations globally. Once CloudFront has a response, subsequent requests for that URL don't reach the server at all. Optimized images are cached at CloudFront for 30 days via Cache-Control headers. For a rarely-requested image that falls out of CloudFront's cache after 30 days, the next request re-primes it from EFS β fast, no reprocessing needed.
Request path through the cache layers
User requests a page
Request hits the nearest CloudFront edge node.
CloudFront hit
If CloudFront has the response cached: done. Served from the edge, never reaches your server. Fastest possible.
CloudFront miss β origin
Cache miss (e.g. first request after deploy). Request forwarded to ALB β ECS container.
Redis hit
Server checks Redis for the page. On hit, serves cached HTML β no Sanity fetch.
Redis miss β Sanity
Cache miss (TTL expired or first request). Server fetches from Sanity, renders page, writes result to Redis.
Image request
Browser requests /_next/image?url=... Server checks EFS. On hit: serves WebP from disk. On miss: fetches original from Sanity CDN, processes, writes to EFS, serves.
CloudFront primed
The response flows back through CloudFront which caches it for next time. Subsequent requests for this URL never reach the server.
Cache Warming
EFS and Redis persist cache across deploys, but after each deploy the new container mounts fresh β the caches are still populated from before, but CloudFront is cold for any URL that wasn't recently requested. Without warming, the first real user after a deploy pays the full slow-path penalty: CloudFront miss β server β EFS/Redis hit (fast), but CloudFront then caches it for everyone after.
The cache warmer runs as the last step in the GitHub Actions deploy workflow. It makes two passes: first hitting every page route (ISR pass), then parsing all /_next/image URLs out of the rendered HTML and requesting each one (image pass). Static page routes are hardcoded in STATIC_PATHS. Blog post slugs are fetched dynamically from Sanity before each run β no manual updates needed when new posts are published.
async function main() {
const blogPaths = await getBlogSlugs();
const allPaths = [...STATIC_PATHS, ...blogPaths];
// Pass 1: hit every page β populates Redis ISR cache
console.log(`ββ Pass 1: ISR cache β warming ${allPaths.length} pages ββ`);
const allImageUrls = new Set();
for (const path of allPaths) {
const html = await warmPage(path);
if (html) {
// collect /_next/image URLs embedded in the SSR HTML
extractImageUrls(html).forEach(u => allImageUrls.add(u));
}
}
// Pass 2: request each image URL β triggers optimizer, writes to EFS, primes CloudFront
console.log(`ββ Pass 2: Image cache β warming ${allImageUrls.size} images ββ`);
for (const imageUrl of allImageUrls) {
await warmImage(imageUrl);
}
}By the time a real user arrives, every page is in Redis and every visible image is in EFS and CloudFront. The warmer simulates the first visit to every URL so no human ever pays the cold-cache penalty. Without it, EFS and Redis would still persist data across deploys β but CloudFront would be cold and the first visitor to each page after a deploy would trigger the CloudFront-to-origin round trip.
Two Incidents
Both of these hit on the same day. They're worth documenting because they illustrate how infrastructure that looks correct can have a silent flaw β one that only manifests under the exact right conditions.
1
The EFS Permissions Bug
The symptom was slow image loads. CloudWatch logs showed hundreds of EACCES: permission denied, mkdir '/app/.next/cache/images' per minute β one for every image request. The image optimizer was running, but it couldn't write the result anywhere. Every image was being reprocessed from scratch on every single request.
The Dockerfile had RUN chown -R nextjs:nodejs /app/.next at build time. That sets ownership correctly inside the image. But at container startup, EFS mounts over /app/.next/cache β replacing that directory with the EFS filesystem, which presents as owned by root. The build-time chown was completely undone at runtime. The fix was an EFS access point configured with posixUser: { uid: 1001, gid: 1001 }, which makes EFS present the mount as owned by uid 1001 regardless of the underlying filesystem.
aws efs create-access-point \
--file-system-id fs-046d1055c95e57951 \
--posix-user Uid=1001,Gid=1001 \
--root-directory "Path=/,CreationInfo={OwnerUid=1001,OwnerGid=1001,Permissions=755}" \
--region us-east-2When Docker or ECS mounts a volume at a path, it replaces whatever was at that path in the image. Build-time changes to that directory β chown, chmod, mkdir β don't survive. This is a fundamental property of how volume mounts work and applies everywhere, not just EFS.
2
The HTTP Listener Sync Bug
The EFS fix required deploying a new ECS task definition revision. That deployment was triggered manually via the AWS CLI rather than through GitHub Actions. The site immediately went 503 on every page.
ECS Express Mode uses two ALB target groups and alternates between them on each canary deploy. It manages the HTTPS:443 listener automatically β shifting weights to the new target group after each deploy. The HTTP:80 listener was created manually (CloudFront connects to the ALB over HTTP to avoid TLS hostname mismatch) and is not managed by ECS. The GitHub Actions deploy workflow has a step that reads the active target group from HTTPS and mirrors the weights to HTTP. Bypassing GitHub Actions meant that step never ran. ECS swapped HTTPS to the new target group; HTTP stayed pointed at the old empty one. CloudFront routes via HTTP β 503.
Running aws ecs update-service directly skips every post-deploy step the workflow handles: the HTTP listener sync, the cache warm, deployment verification. If you need to trigger a hotfix outside of a code push, use workflow_dispatch in the GitHub Actions UI to trigger the workflow manually β that way all the steps still run.
Handling Chunk Errors Mid-Deploy
When a new build deploys, Next.js generates new JS chunks with content-hashed filenames. Any user already on the old version of the site has HTML referencing the old chunk filenames. If they navigate client-side while the deploy is in progress, the browser tries to fetch old chunk URLs that no longer exist β a ChunkLoadError. The fix is a small component mounted in the root layout that listens for this specific error and calls window.location.reload(). The hard reload fetches fresh HTML with the new chunk filenames. The user sees a brief reload instead of a broken page.
'use client';
import { useEffect } from 'react';
export default function ChunkErrorReloader() {
useEffect(() => {
const handler = (event) => {
const err = event.reason ?? event.error;
if (err?.name === 'ChunkLoadError' || err?.message?.includes('Loading chunk')) {
window.location.reload();
}
};
window.addEventListener('unhandledrejection', handler);
window.addEventListener('error', handler);
return () => {
window.removeEventListener('unhandledrejection', handler);
window.removeEventListener('error', handler);
};
}, []);
return null;
}What I'd Do Differently
Test volume mount write permissions before shipping. Running docker exec into the container and trying touch /app/.next/cache/test would have caught the EFS permissions bug immediately. Configure EFS access points by default for any non-root container writing to a volume β not as a post-incident fix.
Add a post-deploy smoke test that verifies both ALB listener weights match before marking the deployment successful. And treat the GitHub Actions pipeline as the only valid deployment path β not a convenience wrapper around CLI commands, but the authoritative record of how this system deploys.