For the first year, Boottify stored all uploaded files on the server's local disk under public/uploads/. This works fine for a single-server deployment with modest traffic. But it has hard limits: no edge caching, no redundancy, and disk I/O bottlenecks when serving images alongside application traffic.
We migrated to AWS S3 + CloudFront CDN with a custom domain at cdn.boottify.com. Here's how.
WHY LOCAL DISK DOESN'T SCALE
- No edge caching — Every image request hits the origin server, even for users on the other side of the world
- Disk I/O contention — Serving static files competes with the Next.js process for disk bandwidth
- No redundancy — If the disk dies, all uploads are gone
- Deployment complexity — Docker builds need to mount or copy the uploads directory
- Scaling to multiple nodes — Shared filesystem (NFS/EFS) adds latency and complexity
S3 BUCKET SETUP
We created an S3 bucket in eu-central-1 (closest to our Hetzner server in Finland) with these settings:
Bucket: aws-boottify
Region: eu-central-1
Versioning: Disabled (we handle versioning at the app level)
Encryption: AES-256 (SSE-S3)
Public Access: Blocked (CloudFront Origin Access Control handles access)
Uploads use immutable cache headers — once an image is stored, it never changes. New versions get new filenames:
await client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
})
);
CLOUDFRONT DISTRIBUTION
CloudFront serves files from S3 through edge locations worldwide. We configured a custom domain cdn.boottify.com with an ACM-issued SSL certificate:
- Origin: S3 bucket via Origin Access Control (OAC) — no public bucket access needed
- Cache Policy: CachingOptimized — respects
Cache-Controlheaders from S3 - Price Class: PriceClass_100 — NA + EU edge locations (where our users are)
- Compression: Enabled for supported types (gzip/brotli)
Image URLs transform from:
// Before (local)
/uploads/blog/building-pipeline.jpg
// After (CDN)
https://cdn.boottify.com/uploads/blog/building-pipeline.jpg
THE getPublicUrl() ABSTRACTION
The getPublicUrl() function in our storage module handles URL generation across three scenarios:
export function getPublicUrl(key: string): string {
if (isS3()) {
// Prefer CloudFront when configured
if (CLOUDFRONT_DOMAIN) {
return `https://${CLOUDFRONT_DOMAIN}/${key}`;
}
// Direct S3 URL as fallback
return `https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com/${key}`;
}
// Local: relative path
return `/${key}`;
}
Application code never constructs URLs manually. It calls getPublicUrl(key) and gets the right URL for the current environment.
CACHE INVALIDATION
For files that get replaced (avatars, logos), we need to invalidate the CloudFront cache. The invalidatePaths() helper handles this:
export async function invalidatePaths(paths: string[]): Promise<void> {
if (!DISTRIBUTION_ID || paths.length === 0) return;
await client.send(
new CreateInvalidationCommand({
DistributionId: DISTRIBUTION_ID,
InvalidationBatch: {
CallerReference: `inv-${Date.now()}`,
Paths: {
Quantity: paths.length,
Items: paths.map((p) => (p.startsWith("/") ? p : `/${p}`)),
},
},
})
);
}
Blog images use unique filenames (timestamp + UUID), so they never need invalidation. Only mutable assets like user avatars trigger cache purges.
NEXT.JS IMAGE CONFIGURATION
To use CDN images with Next.js <Image>, we added the CloudFront domain to next.config.ts:
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.boottify.com" },
{ protocol: "https", hostname: "*.cloudfront.net" },
{ protocol: "https", hostname: "aws-boottify.s3.eu-central-1.amazonaws.com" },
],
}
ENVIRONMENT-DRIVEN SWITCHING
The storage provider is controlled by environment variables:
# Local development (default)
STORAGE_PROVIDER=local
# Production with S3 + CDN
STORAGE_PROVIDER=s3
S3_BUCKET=aws-boottify
S3_REGION=eu-central-1
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
CLOUDFRONT_DOMAIN=cdn.boottify.com
CLOUDFRONT_DISTRIBUTION_ID=E1234...
Developers run locally with filesystem storage. Production uses S3 + CloudFront. The application code is identical in both environments.
THE RESULTS
- Global edge caching — Images served from 20+ CloudFront edge locations
- Zero disk I/O — Application server no longer serves static files
- Immutable caching — 1-year cache headers, instant repeat loads
- Redundant storage — S3 durability: 99.999999999% (11 nines)
- Seamless migration — Existing local URLs still work via Next.js serving



