DEV Community

Cover image for 🏒 Multi-Tenant Image Uploads to S3 via a Generic API Gateway in NestJS
Abhinav
Abhinav

Posted on

🏒 Multi-Tenant Image Uploads to S3 via a Generic API Gateway in NestJS

Learn how to build a scalable, multi-tenant image upload system in NestJS using an API Gateway and Amazon S3.


🧩 System Architecture

Client ──▢ API Gateway ──▢ Consumer Backend ──▢ Amazon S3 
Enter fullscreen mode Exit fullscreen mode

Key goals:

  • βœ… Keep the upload API generic, not specific to any domain logic
  • βœ… Scope files in S3 based on tenantId and customerId
  • βœ… Route requests dynamically using a Gateway Proxy Service

1️⃣ API Gateway – The Traffic Router

Each URL follows a pattern like:

/consumer-api/service/<service-name>/... 
Enter fullscreen mode Exit fullscreen mode

Based on <service-name>, the gateway determines which backend to forward the request to.

✨ ApiGatewayProxyService

@Injectable() export class ApiGatewayProxyService { private readonly baseURLMap: Record<string, string>; constructor(private readonly configService: ConfigService) { this.baseURLMap = { consumers: this.configService.get<string>('TR_CONSUMER_SERVICE_BASE_URL'), // other services removed for simplicity }; } getRequestUrl(requestUrl: string): string { const serviceName = requestUrl.split('/')[3]; switch (serviceName) { case 'consumers': return this.baseURLMap.consumers; default: throw new Error('Service not found'); } } } 
Enter fullscreen mode Exit fullscreen mode

πŸ”„ Generic Proxy Controller

@Controller('consumer-api/service/:serviceName') export class ApiGatewayController { constructor( private readonly proxyService: ApiGatewayProxyService, private readonly httpService: HttpService, ) {} @All('*') async proxy(@Req() req: Request, @Res() res: Response) { const targetBaseUrl = this.proxyService.getRequestUrl(req.url); const urlSuffix = req.url.split('/').slice(4).join('/'); const fullUrl = `${targetBaseUrl}/${urlSuffix}`; try { const response = await this.httpService.axiosRef.request({ url: fullUrl, method: req.method, data: req.body, headers: { ...req.headers, host: new URL(targetBaseUrl).host, }, }); res.status(response.status).send(response.data); } catch (error) { console.error('Proxy Error:', error.message); res.status(error?.response?.status || 500).send(error?.response?.data || 'Internal Error'); } } } 
Enter fullscreen mode Exit fullscreen mode

2️⃣ Consumer Backend – Handling the Upload

This backend receives the proxied request and uploads the image to S3 in a tenant-aware way.

πŸ“₯ Upload Controller

@Controller('consumers/customer-images') export class CustomerImagesController { constructor(private readonly customerImagesService: CustomerImagesService) {} @Post('upload/:id') @UseInterceptors(FileInterceptor('file')) async uploadCustomerImage( @Param('id') customerId: string, @Body('fileName') fileName: string, @UploadedFile() file: Express.Multer.File, ) { if (!file) { throw new BadRequestException('No file uploaded'); } return this.customerImagesService.uploadCustomerImage(customerId, fileName, file); } } 
Enter fullscreen mode Exit fullscreen mode

🧠 Upload Service

@Injectable({ scope: Scope.REQUEST }) export class CustomerImagesService { constructor( private readonly s3Service: S3Service, @Inject(REQUEST) private readonly request: Request, ) {} async uploadCustomerImage(customerId: string, fileName: string, file: Express.Multer.File) { const tenantId = this.request.headers['x-tenant-id'] as string; if (!tenantId) { throw new BadRequestException('Tenant ID missing'); } const filePath = path.join(tenantId, 'customer', customerId, file.originalname); await this.s3Service.uploadFile(file, customerId, filePath); return { message: 'Image uploaded successfully', storedName: filePath, customerId, }; } } 
Enter fullscreen mode Exit fullscreen mode

3️⃣ Uploading to S3

async uploadFile(file: Express.Multer.File, customerId: string, filePath: string) { await this.s3Client.send( new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: filePath, Body: file.buffer, ContentType: file.mimetype, }), ); } 
Enter fullscreen mode Exit fullscreen mode

πŸ” Testing the Upload

curl -X POST http://localhost:3000/consumer-api/service/consumers/customer-images/upload/12345 \ -H "x-tenant-id: tenant-abc" \ -F "file=@./image.jpg" 
Enter fullscreen mode Exit fullscreen mode

πŸ“¦ Sample Response

{ "message": "Image uploaded successfully", "storedName": "tenant-abc/customer/12345/image.jpg", "customerId": "12345" } 
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Why This Works Well

  • βš™οΈ Generic gateway routes keep your code DRY and scalable
  • πŸ” Tenant-aware file paths offer better isolation and security
  • 🧼 Clean separation between gateway and backend logic
  • πŸ“¦ Easy to plug in more services under the same pattern

πŸš€ What’s Next?

  • πŸ” Add authentication and rate-limiting to your gateway
  • πŸ“ Persist upload metadata for tracking
  • πŸ–ΌοΈ Switch to pre-signed S3 URLs for client-side uploads
  • ⚑ Trigger async workflows (e.g. AI processing) post-upload

🧠 Final Thoughts

Using a generic gateway and tenant-aware backend gives us a scalable, maintainable foundation for handling user uploads β€” especially in multi-tenant environments.

This design works great for SaaS platforms, consumer apps, and enterprise tools alike. You don’t just build features β€” you build systems that scale. πŸ’ͺ

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.