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
Key goals:
- β Keep the upload API generic, not specific to any domain logic
- β
Scope files in S3 based on
tenantId
andcustomerId
- β 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>/...
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'); } } }
π 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'); } } }
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); } }
π§ 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, }; } }
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, }), ); }
π 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"
π¦ Sample Response
{ "message": "Image uploaded successfully", "storedName": "tenant-abc/customer/12345/image.jpg", "customerId": "12345" }
π‘ 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.