Announcing @nestjs/storage.

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Today I'm excited to announce @nestjs/storage, a new package for storing files in Nest. It gives you one API for local disks and S3-compatible services (AWS S3, Cloudflare R2, MinIO, Backblaze B2), along with everything that usually surrounds it: safe uploads, signed download links, direct uploads from the browser or a mobile app, and an in-memory disk for tests.

In case you're not familiar with NestJS, it is a TypeScript Node.js framework that helps you build enterprise-grade efficient and scalable Node.js applications.

Let's dive right in! 🐈

Why a storage package?

Almost every application stores files at some point: product photos, avatars, invoices, exports. And almost every time, it starts the same way: fs.writeFile() in development and the AWS SDK in production, with an if somewhere in between. Then come the details, and they're where things get tricky. Is that upload really an image, or just a file called photo.png? What happens to a half-written file when the client disconnects? How do you let a customer download their invoice without making it public to the entire internet?

@nestjs/storage answers all of these questions once, so you don't have to answer them in every project.

Getting started

First, install the package (along with @nestjs/config, which we'll use to build the options):

$ npm i --save @nestjs/storage @nestjs/config

Files live on disks: named storage locations that all share the same API. Let's set up two of them, one for public product photos and one for private files:

import { ConfigService } from '@nestjs/config';
import { LocalDisk, type StorageModuleOptions } from '@nestjs/storage';
export function createStorageOptions(config: ConfigService): StorageModuleOptions {
const appUrl = config.getOrThrow<string>('appUrl');
const root = config.getOrThrow<string>('storage.root');
return {
default: 'private',
disks: {
photos: new LocalDisk({ root: `${root}/photos`, publicUrl: `${appUrl}/photos` }),
private: new LocalDisk({
root: `${root}/private`,
signedUrls: {
baseUrl: `${appUrl}/files`,
keys: [config.getOrThrow<string>('storage.signingKey')],
},
}),
},
};
}

and register the module:

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
StorageModule.forRootAsync({
inject: [ConfigService],
useFactory: createStorageOptions,
}),
],
})
export class AppModule {}

Now you can inject the default disk as StorageDisk, a specific one with @InjectDisk('photos'), or all of them at once through the Storage class.

Every disk exposes the same set of methods: put() (atomic writes), get() (metadata and a stream of the body), exists(), stat(), delete(), list(), url(), signedUrl(), and signedUpload(). Your services never need to know where the bytes actually end up.

Uploads, done right

Here's a controller that accepts a product photo:

@Post('products/:id/photo')
@UseInterceptors(
FileInterceptor('photo', {
storage: uploadToDisk({ disk: 'photos', contentTypes: PHOTO_TYPES, cacheControl: PHOTO_CACHE_CONTROL }),
limits: { fileSize: MAX_PHOTO_SIZE, files: 1 },
}),
)
upload(@Param('id') id: string, @UploadedFile() file: StoredUpload | undefined) {
if (!file) {
throw new BadRequestException('Attach the image as the "photo" field');
}
return this.photosService.replace(id, file.key);
}

uploadToDisk() is a storage engine for the FileInterceptor you already know, and it does a lot more than just copy bytes:

  • The content type is detected from the file itself, not taken from what the client claims. An HTML file renamed to photo.png is rejected with 415 Unsupported Media Type.
  • The size limit is enforced without leaving partial files behind.
  • The key is generated for you (a UUID plus the right extension), so a user-supplied file name can never overwrite somebody else's file.

Serving the photo back is just as simple, with Range and ETag support included:

@Get('photos/:file')
serve(@Param('file') file: string, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
return serveFile(this.photos, file, { req, res, disposition: 'inline' });
}

Private files and signed URLs

Invoices are a different story. They must never be public, but the customer who paid for the order should be able to download theirs. That's what signed URLs are for:

async downloadUrl(orderId: string) {
const order = this.ordersService.findOne(orderId);
const key = `invoices/${order.id}.pdf`;
if (!(await this.storageDisk.exists(key))) {
await this.storageDisk.put(key, renderInvoice(order), {
contentType: 'application/pdf',
metadata: { orderId: order.id },
});
}
const url = await this.storageDisk.signedUrl(key, {
expiresIn: '5m',
filename: `invoice-${order.id}.pdf`,
});
return { url };
}

The link works for 5 minutes, and only for this one file. The signature covers the route, the key, the expiry, and the file name, so changing any of them invalidates it. On S3, this is a regular presigned URL. On a local disk, a tiny controller verifies the signature and streams the file, which means your development setup behaves exactly like production:

@Controller('files')
export class FilesController {
constructor(private readonly storageDisk: StorageDisk) {}
@Get()
download(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return serveSignedUrl(this.storageDisk, { req, res });
}
}

Need to rotate your signing key? Put the new one first in the keys array. New links are signed with it, while links signed with the old key keep working until they expire.

Direct uploads

Streaming a 20 MB photo from a mobile app through your API only to forward it to S3 is a waste of bandwidth and memory. With signedUpload(), the app uploads straight to storage in three steps:

  1. The app asks your API for an upload URL, declaring the content type and size.
  2. It PUTs the file directly to that URL.
  3. It tells your API the upload is done, and your API verifies the file before using it.
async createUpload(productId: string, contentType: string, size: number) {
// ...validate contentType and size first
const key = `incoming/${productId}/${randomUUID()}`;
const upload = await this.uploads.signedUpload(key, {
contentType,
contentLength: size,
expiresIn: '10m',
});
return { key, ...upload };
}

The upload URL only accepts exactly the declared content type and length. S3 enforces this at the bucket level, while for local disks, receiveSignedUpload() does the same on your server. And when the upload completes, detectContentType() lets you check the first bytes of the file before you accept it, because, once again, never trust the client. 🙃

Going to production

Here's the best part. Moving from local disks to S3 (or R2, MinIO, B2) is a change in the options factory, and not a single line in your services or controllers:

if (config.get('storage.driver') === 's3') {
const s3 = {
endpoint: config.get<string>('storage.s3.endpoint'),
region: config.getOrThrow<string>('storage.s3.region'),
};
return {
default: 'private',
disks: {
photos: new S3Disk({
...s3,
bucket: config.getOrThrow<string>('storage.s3.photosBucket'),
publicUrl: config.getOrThrow<string>('storage.s3.photosUrl'),
}),
private: new S3Disk({
...s3,
bucket: config.getOrThrow<string>('storage.s3.privateBucket'),
}),
},
};
}

For AWS S3, set the region. For Cloudflare R2, set the endpoint and region: 'auto'. For MinIO, add forcePathStyle: true.

Errors you can actually act on

Failures are typed, and all of them extend the StorageError class:

  • StorageFileNotFoundError - the key doesn't exist (404)
  • StorageInvalidKeyError - the key breaks the key rules (400)
  • StorageBodyLengthError - the body doesn't match the declared length (400)
  • StorageSignedUrlError - a bad signature, an expired link, or the wrong method (403)
  • StorageServiceError - the storage provider itself failed

Testing

Override the options with InMemoryDisks, and your tests run without touching the file system (or S3):

const photos = new InMemoryDisk({ publicUrl: 'https://photos.example.com' });
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(STORAGE_MODULE_OPTIONS)
.useValue({ default: 'private', disks: { photos, private: files } })
.compile();

Then simply look inside:

const [key] = photos.keys();
expect(res.body.photoUrl).toBe(`https://photos.example.com/${key}`);
expect(await photos.getBuffer(key)).toEqual(PNG);

Try it today!

$ npm i --save @nestjs/storage

The new File storage chapter in the official documentation covers everything above in depth, including the full direct upload flow, S3 provider setup, and a production checklist (separate buckets, lifecycle rules, CORS, IAM, encryption, and more). Give it a try, and please open an issue if anything gets in your way!

Happy coding! 🐈


Learn NestJS - Official NestJS Courses 📚

Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈

🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!

🎉 NEW - NestJS Course Extensions now live!
#NestJS
#NodeJS

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Distributed Locks with @nestjs/locks

Meet @nestjs/locks, distributed locks for NestJS: run scheduled jobs on one instance, prevent overlapping runs, and elect a leader, with leases and fencing tokens.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Sending Email with @nestjs/mail

Meet @nestjs/mail, a new package for transactional email in NestJS with typed mail classes, safe templates, SMTP and HTTP transports, and outbox integration.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Introducing @nestjs/resilience

Meet @nestjs/resilience, retries, timeouts, circuit breakers, bulkheads, and fallbacks for NestJS controllers, resolvers, microservices, and services.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
© 2019-2026 Trilon.