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.