CDN Optimization with CloudFront
Future Phase: CDN & Image Optimization
When to Implement
- β’Monthly active users exceed 10K
- β’Users report slow image loading (>500ms)
- β’S3 transfer costs exceed $50/month
- β’Need image variants (thumbnails) for performance
Migration Steps
- β’Update
STORAGE_PUBLIC_URLenv var to CloudFront URL - β’Revert S3 bucket to private (block public access)
- β’Add CloudFront OAI to bucket policy
Overview
Add CloudFront CDN for low-latency delivery and Sharp-based image optimization for thumbnails and format conversion.
Requirements
Functional:
- β’CloudFront distribution with S3 origin
- β’Image resizing (thumbnails: 150x150, medium: 600x600)
- β’WebP conversion for modern browsers
- β’Quality optimization (75-80%)
Non-functional:
- β’Cache-Control: 1 year for immutable images
- β’CDN latency: <50ms for Vietnam users
- β’Processing: <2s for typical images
Architecture
Option A: On-Upload Processing (Recommended for MVP)
Upload β S3 β Lambda trigger β Sharp resize β S3 variants
Option B: On-Demand Processing (Future)
Request β CloudFront β Lambda@Edge β Sharp β Cache
Starting with Option A for simplicity.
Implementation Steps
1. Create CloudFront Distribution
# Create CloudFront OAI
aws cloudfront create-cloud-front-origin-access-identity \
--cloud-front-origin-access-identity-config '{
"CallerReference": "pickleballdot-storage-oai",
"Comment": "OAI for pickleballdot-storage"
}'
# Create distribution (use AWS Console or Terraform for full config)
CloudFront configuration:
- β’Origin:
pickleballdot-storage.s3.ap-southeast-1.amazonaws.com - β’Origin Access: Origin Access Identity (OAI)
- β’Viewer Protocol: Redirect HTTP to HTTPS
- β’Cache Policy: CachingOptimized
- β’Price Class: PriceClass_200 (excludes South America, Australia)
2. Update S3 Bucket Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity XXXXXX"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::pickleballdot-storage/*"
}
]
}
3. Add Sharp Dependency
cd pickleballdot-admin-be
pnpm add sharp
pnpm add -D @types/sharp
4. Create Image Processing Service
src/modules/storage/services/image-processing.service.ts:
import { Injectable, Logger } from '@nestjs/common';
import * as sharp from 'sharp';
export interface ImageVariant {
suffix: string;
width: number;
height: number;
quality: number;
}
export const IMAGE_VARIANTS: ImageVariant[] = [
{ suffix: 'thumb', width: 150, height: 150, quality: 80 },
{ suffix: 'medium', width: 600, height: 600, quality: 80 },
{ suffix: 'large', width: 1200, height: 1200, quality: 85 },
];
@Injectable()
export class ImageProcessingService {
private readonly logger = new Logger(ImageProcessingService.name);
async processImage(
buffer: Buffer,
variants: ImageVariant[] = IMAGE_VARIANTS,
): Promise<Map<string, Buffer>> {
const results = new Map<string, Buffer>();
for (const variant of variants) {
try {
const processed = await sharp(buffer)
.resize(variant.width, variant.height, {
fit: 'cover',
withoutEnlargement: true,
})
.webp({ quality: variant.quality })
.toBuffer();
results.set(variant.suffix, processed);
} catch (error) {
this.logger.error(`Failed to process ${variant.suffix}`, error);
}
}
// Also create original WebP
try {
const originalWebp = await sharp(buffer)
.webp({ quality: 85 })
.toBuffer();
results.set('original', originalWebp);
} catch (error) {
this.logger.error('Failed to convert to WebP', error);
}
return results;
}
async getImageMetadata(buffer: Buffer) {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width,
height: metadata.height,
format: metadata.format,
size: buffer.length,
};
}
}
5. Update Storage Service for Variants
Add to src/modules/storage/storage.service.ts:
import { ImageProcessingService, IMAGE_VARIANTS } from './services/image-processing.service';
@Injectable()
export class StorageService {
constructor(
private config: ConfigService,
private imageProcessing: ImageProcessingService,
) {}
async uploadWithVariants(
buffer: Buffer,
baseKey: string,
contentType: string,
): Promise<{ original: string; variants: Record<string, string> }> {
const ext = baseKey.split('.').pop();
const basePath = baseKey.replace(`.${ext}`, '');
// Upload original
await this.uploadBuffer(buffer, baseKey, contentType);
// Process and upload variants
const variants = await this.imageProcessing.processImage(buffer);
const variantUrls: Record<string, string> = {};
for (const [suffix, variantBuffer] of variants) {
const variantKey = `${basePath}-${suffix}.webp`;
await this.uploadBuffer(variantBuffer, variantKey, 'image/webp');
variantUrls[suffix] = `${this.cdnUrl}/${variantKey}`;
}
return {
original: `${this.cdnUrl}/${baseKey}`,
variants: variantUrls,
};
}
private async uploadBuffer(
buffer: Buffer,
key: string,
contentType: string,
): Promise<void> {
await this.s3Client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: 'public, max-age=31536000, immutable',
}),
);
}
}
6. Server-Side Upload Endpoint (For Processing)
Add alternative endpoint for server-side processing:
@Post('upload-with-processing')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/^image\/(jpeg|png|webp|gif)$/)) {
return cb(new BadRequestException('Only images allowed'), false);
}
cb(null, true);
},
}))
@ApiConsumes('multipart/form-data')
async uploadWithProcessing(
@UploadedFile() file: Express.Multer.File,
@GetUser('sub') userId: string,
@Body('category') category: UploadCategory,
@Body('entityId') entityId: string,
) {
const baseKey = `${category}/${entityId}/${randomUUID().slice(0, 8)}.${file.originalname.split('.').pop()}`;
return this.storageService.uploadWithVariants(file.buffer, baseKey, file.mimetype);
}
7. Environment Update
Add to .env:
AWS_S3_CDN_URL=https://d123abc.cloudfront.net
8. Cache Headers Configuration
Ensure all uploaded files have proper cache headers:
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: fileKey,
ContentType: dto.contentType,
CacheControl: 'public, max-age=31536000, immutable',
});
Related Code Files
- β’Create:
src/modules/storage/services/image-processing.service.ts - β’Modify:
src/modules/storage/storage.service.ts - β’Modify:
src/modules/storage/storage.controller.ts - β’Modify:
src/modules/storage/storage.module.ts - β’Create:
infrastructure/cloudfront/distribution.tf(optional)
Success Criteria
- β’ CloudFront distribution created and working
- β’ Images served via CDN URL (not S3 direct)
- β’ Sharp processing generates thumb/medium/large variants
- β’ WebP conversion working
- β’ Cache-Control headers set to 1 year
- β’ CDN latency <100ms from Vietnam
Risk Assessment
| Risk | Mitigation |
|---|---|
| Sharp memory issues | Limit concurrent processing, queue heavy loads |
| CloudFront invalidation cost | Use versioned keys, avoid invalidation |
| Processing timeout | Set Lambda timeout 30s, handle gracefully |
| WebP browser support | Fallback to original format for old browsers |