DEV Community

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

Posted on

3 1 1 1 1

🏢 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. 💪

ACI image

ACI.dev: The Only MCP Server Your AI Agents Need

ACI.dev’s open-source tool-use platform and Unified MCP Server turns 600+ functions into two simple MCP tools on one server—search and execute. Comes with multi-tenant auth and natural-language permission scopes. 100% open-source under Apache 2.0.

Star our GitHub!

Top comments (0)

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

Tiger Data image

🐯 🚀 Timescale is now TigerData: Building the Modern PostgreSQL for the Analytical and Agentic Era

We’ve quietly evolved from a time-series database into the modern PostgreSQL for today’s and tomorrow’s computing, built for performance, scale, and the agentic future.

So we’re changing our name: from Timescale to TigerData. Not to change who we are, but to reflect who we’ve become. TigerData is bold, fast, and built to power the next era of software.

Read more

👋 Kindness is contagious

Explore this insightful write-up, celebrated by our thriving DEV Community. Developers everywhere are invited to contribute and elevate our shared expertise.

A simple "thank you" can brighten someone’s day—leave your appreciation in the comments!

On DEV, knowledge-sharing fuels our progress and strengthens our community ties. Found this useful? A quick thank you to the author makes all the difference.

Okay