AWS S3 Storage System/Phase 6 of 61-2 hours

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

  1. β€’Update STORAGE_PUBLIC_URL env var to CloudFront URL
  2. β€’Revert S3 bucket to private (block public access)
  3. β€’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

RiskMitigation
Sharp memory issuesLimit concurrent processing, queue heavy loads
CloudFront invalidation costUse versioned keys, avoid invalidation
Processing timeoutSet Lambda timeout 30s, handle gracefully
WebP browser supportFallback to original format for old browsers