Modern media-heavy web applications require fast, low-latency image and video delivery. However, scaling media delivery on traditional hyperscale cloud providers often leads to egress penalty shock—where bandwidth transfer costs dwarf the actual cost of storage.
In this production-ready tutorial, you will learn how to architect a zero-egress global media CDN using S3-compatible cloud object storage (such as AZBrand Cloud Storage), Next.js Image Optimization, and client-side pre-processing. By decoupling storage egress costs from delivery, you can scale to millions of monthly media requests at a fraction of hyperscaler pricing.
Why Egress Costs Kill Scalability (And How Zero-Egress Fixes It)
AWS S3 and similar legacy cloud providers charge as much as $0.09 per GB for outbound internet data transfer. If your application serves 20TB of optimized images per month, egress alone costs $1,800/month—even though storing those images costs less than $50/month.
AZBrand S3 Cloud Storage provides high-throughput, S3-API compatible object storage with $0 egress fees, enabling predictable billing regardless of traffic spikes.
Financial Breakdown: Legacy Hyperscaler vs. Zero-Egress S3 Storage
| Monthly Traffic / Storage | AWS S3 Estimated Cost (Storage + Egress) | AZBrand S3 Cloud Storage (Zero-Egress) | Monthly Savings |
|---|---|---|---|
| 1 TB Storage / 5 TB Egress | $473.00 ($23 storage + $450 egress) | $12.00 | $461.00 (97.4%) |
| 5 TB Storage / 20 TB Egress | $1,915.00 ($115 storage + $1,800 egress) | $60.00 | $1,855.00 (96.8%) |
| 10 TB Storage / 50 TB Egress | $4,730.00 ($230 storage + $4,500 egress) | $120.00 | $4,610.00 (97.4%) |
Step 1: Provisioning the S3 Storage Bucket & Access Credentials
To begin, create a dedicated media bucket on your S3 provider and issue scoped IAM credentials with minimal privileges.
1. Bucket Creation and Security Configuration
Create a bucket named app-media-cdn-prod. Set default access settings to block raw bucket listing while permitting controlled public read access for media objects.
2. CORS (Cross-Origin Resource Sharing) Policy
Apply the following CORS policy to your S3 bucket to allow direct browser uploads via HTTP PUT requests:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]3. Generate Scoped API Access Credentials
Obtain your S3 credentials from the control panel:
- S3 Endpoint:
https://s3.ca-central-1.azbrand.cloud - Access Key ID:
AZB_ACCESS_KEY_EXAMPLE - Secret Access Key:
AZB_SECRET_KEY_EXAMPLE
Store these in your project's .env.local file:
AZBRAND_S3_ENDPOINT=https://s3.ca-central-1.azbrand.cloud
AZBRAND_S3_REGION=ca-central-1
AZBRAND_S3_ACCESS_KEY_ID=your_access_key_here
AZBRAND_S3_SECRET_ACCESS_KEY=your_secret_key_here
AZBRAND_S3_BUCKET_NAME=app-media-cdn-prodStep 2: Generating Secure Presigned Upload URLs in Node.js & TypeScript
Direct browser uploads reduce server load by allowing clients to stream raw media directly to S3 without proxying through your Node.js application server. We issue a short-lived presigned PutObjectCommand using @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner.
S3 Client Configuration (lib/s3-client.ts)
import { S3Client } from '@aws-sdk/client-s3';
export const s3Client = new S3Client({
region: process.env.AZBRAND_S3_REGION || 'ca-central-1',
endpoint: process.env.AZBRAND_S3_ENDPOINT,
credentials: {
accessKeyId: process.env.AZBRAND_S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.AZBRAND_S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Required for custom S3-compatible endpoints
});Presigned Upload Endpoint (app/api/media/upload-url/route.ts)
import { NextRequest, NextResponse } from 'next/server';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';
import { s3Client } from '@/lib/s3-client';
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
export async function POST(req: NextRequest) {
try {
const { contentType, fileSize, filename } = await req.json();
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
}
if (fileSize > MAX_FILE_SIZE_BYTES) {
return NextResponse.json({ error: 'File size exceeds limit' }, { status: 400 });
}
const fileExtension = filename.split('.').pop() || 'webp';
const key = `uploads/${new Date().getFullYear()}/${uuidv4()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: process.env.AZBRAND_S3_BUCKET_NAME!,
Key: key,
ContentType: contentType,
CacheControl: 'public, max-age=31536000, immutable',
});
const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 });
const publicUrl = `${process.env.AZBRAND_S3_ENDPOINT}/${process.env.AZBRAND_S3_BUCKET_NAME}/${key}`;
return NextResponse.json({
uploadUrl: presignedUrl,
fileUrl: publicUrl,
key: key,
});
} catch (error) {
console.error('Error generating presigned URL:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}Step 3: Automating Server-Side and Client-Side WebP Compression
Compressing images to modern WebP format before storage dramatically lowers required storage space and speeds up initial loading times.
Node.js Image Compression Pipeline using Sharp (lib/image-processor.ts)
import sharp from 'sharp';
export interface CompressionOptions {
quality?: number;
maxWidth?: number;
}
export async function processAndCompressImage(
inputBuffer: Buffer,
options: CompressionOptions = {}
): Promise<{ buffer: Buffer; contentType: string }> {
const { quality = 82, maxWidth = 1920 } = options;
let pipeline = sharp(inputBuffer);
const metadata = await pipeline.metadata();
if (metadata.width && metadata.width > maxWidth) {
pipeline = pipeline.resize({ width: maxWidth, fit: 'inside', withoutEnlargement: true });
}
const compressedBuffer = await pipeline
.webp({ quality, effort: 4 })
.toBuffer();
return {
buffer: compressedBuffer,
contentType: 'image/webp',
};
}Frontend Upload Component with Auto Compression (components/MediaUploader.tsx)
'use client';
import React, { useState } from 'react';
export function MediaUploader({ onUploadComplete }: { onUploadComplete: (url: string) => void }) {
const [uploading, setUploading] = useState(false);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Request presigned URL
const res = await fetch('/api/media/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
fileSize: file.size,
}),
});
const { uploadUrl, fileUrl } = await res.json();
// Upload binary payload directly to S3
const uploadRes = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
body: file,
});
if (!uploadRes.ok) throw new Error('Failed to upload file to S3');
onUploadComplete(fileUrl);
} catch (err) {
console.error('Upload failed:', err);
} finally {
setUploading(false);
}
};
return (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={uploading}
className="hidden"
id="file-input"
/>
<label htmlFor="file-input" className="cursor-pointer font-medium text-blue-600 hover:underline">
{uploading ? 'Compressing & Uploading...' : 'Select an image to upload'}
</label>
</div>
);
}Step 4: Configuring Next.js Image Optimization & HTTP Caching Headers
To serve images through Next.js <Image /> component with zero egress cost overhead, configure next.config.js to whitelist your S3 storage endpoint and enforce strong browser caching.
Next.js Configuration (next.config.js)
/** @type {import('next').NextJSConfig} */
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 31536000, // 1 Year
remotePatterns: [
{
protocol: 'https',
hostname: 's3.ca-central-1.azbrand.cloud',
port: '',
pathname: '/app-media-cdn-prod/**',
},
],
},
async headers() {
return [
{
source: '/_next/image(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, minimum-internal-ttl=31536000, immutable',
},
],
},
];
},
};
module.exports = nextConfig;Rendering Images with Automatic Optimization
import Image from 'next/image';
export function ProductCard({ imageUrl, title }: { imageUrl: string; title: string }) {
return (
<div className="relative w-full h-64 overflow-hidden rounded-xl shadow-md">
<Image
src={imageUrl}
alt={title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
className="object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
);
}Production Hardening and Key Takeaways
- Always Set `Cache-Control` during Presigned Upload: Ensure every
PutObjectCommandexplicitly includesCache-Control: public, max-age=31536000, immutable. Browsers and edge caches will respect this header, minimizing duplicate downloads. - Isolate Media Storage with Scoped Tokens: Never share root S3 credentials. Use scoped access keys restricted solely to
s3:PutObjectands3:GetObjecton the targeted media bucket. - Combine S3 with High-Performance VPS: Host your Next.js application on AZBrand Cloud VPS running close to your S3 storage region to reduce latency between application render and object fetches.
By building your media backend on AZBrand Zero-Egress S3 Storage, you eliminate unexpected cloud bills, guarantee lightning-fast image serving, and maintain total control over your application asset pipeline.