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.