Integrating Cloudinary with NestJS for Image Management
Image handling in backend systems is easy to start and easy to break under real traffic. A clean integration pattern with Cloudinary and NestJS helps you keep uploads stable, metadata consistent, and deletion logic predictable.
This guide shows a modular setup with provider-based configuration, upload workflows, and safe resource deletion.
Why It Matters
- Keeps media operations isolated from business controllers.
- Stores image metadata for audit, ownership, and retrieval.
- Uses stream-based upload for memory-friendly processing.
- Makes cleanup deterministic across cloud storage and database.
Core Concepts
1. Cloudinary Provider Configuration
Create a dedicated provider that loads credentials from environment variables.
import { Provider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { v2 as CloudinaryAPI } from "cloudinary";
export const CLOUDINARY = "CLOUDINARY";
export const CloudinaryProvider: Provider = {
provide: CLOUDINARY,
useFactory: (configService: ConfigService) =>
CloudinaryAPI.config({
cloud_name: configService.get<string>("CLD_CLOUD_NAME"),
api_key: configService.get<string>("CLD_API_KEY"),
api_secret: configService.get<string>("CLD_API_SECRET"),
}),
inject: [ConfigService],
};
2. Service Skeleton
Use a service layer to orchestrate upload and metadata persistence.
import {
BadRequestException,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { ImageMetaRepository } from "./image-meta.repository";
@Injectable()
export class ImageMetaService {
private readonly logger = new Logger(ImageMetaService.name);
constructor(private readonly imageMetaRepository: ImageMetaRepository) {}
}
3. Single Image Upload
Validate file input, upload to Cloudinary, then persist metadata.
async createSingleImage(
singleImageFile: Express.Multer.File,
ownerId: string
): Promise<ImageMetaDocument> {
try {
if (!singleImageFile) {
throw new BadRequestException("No image file provided");
}
const extension = this.getFileExtension(singleImageFile.originalname);
const uploadResult = await this.uploadImageToCloudinary(singleImageFile);
const createdImage = await this.imageMetaRepository.create({
url: uploadResult.secure_url,
name: uploadResult.public_id,
extension,
size: singleImageFile.size,
mimeType: singleImageFile.mimetype,
ownerId,
});
return createdImage;
} catch (error) {
this.logger.error("Error creating single image", error as Error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException("Failed to create single image");
}
}
4. Multiple Image Upload
Process many files concurrently using Promise.all.
async createMultipleImages(
multipleImageFiles: Express.Multer.File[],
ownerId: string
): Promise<ImageMetaDocument[]> {
try {
if (!multipleImageFiles || multipleImageFiles.length === 0) {
throw new BadRequestException("No image files provided");
}
return await Promise.all(
multipleImageFiles.map((imageFile) => this.createSingleImage(imageFile, ownerId))
);
} catch (error) {
this.logger.error("Error creating multiple images", error as Error);
if (error instanceof HttpException) {
throw error;
}
throw new InternalServerErrorException("Failed to create multiple images");
}
}
5. Image Deletion Workflow
Delete from cloud storage and metadata store in sequence.
async removeImage(imageId: string, ownerId: string): Promise<ImageMetaDocument> {
try {
const image = await this.imageMetaRepository.getOneWhere({
_id: imageId,
ownerId,
});
if (!image) {
throw new BadRequestException(`Image not found: ${imageId}`);
}
await this.deleteImageFromCloudinary(image.name);
await this.imageMetaRepository.removeOneById(imageId);
return image;
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
this.logger.error("Error deleting image", error as Error);
throw new InternalServerErrorException("Could not delete image");
}
}
6. Stream-Based Cloudinary Upload
Upload image buffers using upload_stream for efficient memory usage.
import { v2 as CloudinaryAPI, UploadApiErrorResponse, UploadApiResponse } from "cloudinary";
import toStream from "buffer-to-stream";
async uploadImageToCloudinary(file: Express.Multer.File): Promise<UploadApiResponse> {
return await new Promise<UploadApiResponse>((resolve, reject) => {
const uploadStream = CloudinaryAPI.uploader.upload_stream(
(error: UploadApiErrorResponse | undefined, result: UploadApiResponse | undefined) => {
if (error) {
reject(error);
return;
}
if (!result) {
reject(new Error("Upload result is undefined"));
return;
}
resolve(result);
}
);
toStream(file.buffer).pipe(uploadStream);
});
}
Practical Example
Module registration for reusable image service:
import { Global, Module } from "@nestjs/common";
import { CloudinaryProvider } from "../../utility/provider/cloudinary.provider";
import { ImageMetaService } from "./image-meta.service";
@Global()
@Module({
providers: [ImageMetaService, CloudinaryProvider],
exports: [ImageMetaService],
})
export class ImageMetaModule {}
This gives you one place to manage uploads and deletions, so controllers do not become part-time file managers.
Common Mistakes
- Uploading files without validating input presence and owner context.
- Saving metadata before confirming cloud upload success.
- Deleting DB records first and leaving orphaned cloud assets.
- Hardcoding Cloudinary credentials instead of environment variables.
- Ignoring concurrency and timeout behavior in bulk uploads.
Quick Recap
- Provider pattern keeps Cloudinary configuration centralized.
- Service layer handles upload, metadata persistence, and deletion.
Promise.allsupports efficient multi-image workflows.- Stream uploads improve memory behavior for file buffers.
- Modular composition keeps media logic reusable across the app.
Next Steps
- Add file type and size validation with
ParseFilePipeand validators. - Add transformation presets (thumbnail, optimized formats) in upload options.
- Add soft-delete metadata strategy for recovery workflows.
- Add background cleanup jobs for failed partial operations.