Skip to main content

Add S3-compatible file uploads to a NestJS app in 10 minutes

· 5 min read
The OpenBucket team
Maintainers of OpenBucket

Every app eventually needs to store files — avatars, attachments, exports. And in Node/NestJS the usual options are all a bit of a chore: pay for AWS S3 and juggle credentials, stand up and babysit a MinIO container, or hack something onto local disk that you'll have to rip out later.

OpenBucket takes a different route: it's an S3-compatible object store you npm install straight into your NestJS app. No second service, no container in local dev, no cloud account. In this tutorial we'll add a working upload endpoint — with validation and a shareable URL — in about ten minutes.

What we're building

A POST /files endpoint that accepts a multipart upload, streams it straight into OpenBucket (no temp file, no file.buffer in memory), validates it, and returns a short-lived URL to fetch it back. The same object store also gives you a bundled admin console to browse what you uploaded.

Step 1 — Install

npm install @openbucket/nestjs

multer is an optional peer dependency used for the upload pipeline:

npm install multer

Step 2 — Generate the secrets

OpenBucket refuses to boot with weak or missing secrets. You need an argon2id hash of your admin password and a couple of random secrets. The package ships a CLI, so you can mint the hash with no repository checkout:

# argon2id hash for the admin password:
npx @openbucket/nestjs hash 'choose-a-strong-password'

# two random 32-char secrets (JWT signing + the S3 root secret):
openssl rand -hex 32
openssl rand -hex 32

Drop them into your environment (e.g. a .env):

OB_ADMIN_HASH='$argon2id$v=19$m=65536,...' # from `openbucket hash`
OB_JWT_SECRET='…32+ chars…'
OB_ACCESS_KEY_ID='AKIALOCALDEV0000000' # 16–32 uppercase alphanumerics
OB_SECRET_ACCESS_KEY='…32+ chars…'

Step 3 — Register the module

Mount OpenBucket under a path prefix in your app. Everything — the S3 endpoint, the admin API, and the admin console — lives under mountPath.

app.module.ts
import { Module } from '@nestjs/common';
import { OpenBucketModule } from '@openbucket/nestjs';
import { FilesController } from './files.controller';

@Module({
imports: [
OpenBucketModule.forRoot({
dataDir: './.openbucket-data', // SQLite + blob payloads live here
mountPath: '/storage', // S3 endpoint = http://localhost:3000/storage
rootCredentials: {
accessKeyId: process.env.OB_ACCESS_KEY_ID!,
secretAccessKey: process.env.OB_SECRET_ACCESS_KEY!,
},
admin: {
username: 'admin',
passwordHash: process.env.OB_ADMIN_HASH!,
jwtSecret: process.env.OB_JWT_SECRET!,
serveUi: true, // admin console at /storage/admin
},
}),
],
controllers: [FilesController],
})
export class AppModule {}

Step 4 — Create a bucket on startup

Create the uploads bucket once, when the app boots, using the in-process OpenBucketService (no HTTP round-trip):

app.bootstrap.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { OpenBucketService } from '@openbucket/nestjs';

@Injectable()
export class BucketBootstrap implements OnModuleInit {
constructor(private readonly ob: OpenBucketService) {}

async onModuleInit() {
if (!(await this.ob.bucketExists('uploads'))) {
await this.ob.createBucket('uploads');
}
}
}

Add BucketBootstrap to your module's providers.

Step 5 — The upload endpoint

Here's the whole thing. OpenBucketFileInterceptor streams the multipart part straight into the store — no temp file, no file.buffer. The @UploadedToBucket() decorator hands you the committed object, and UploadValidationExceptionFilter turns a rejected upload into a clean HTTP 400.

files.controller.ts
import {
Controller,
Post,
UseFilters,
UseInterceptors,
} from '@nestjs/common';
import {
OpenBucketFileInterceptor,
UploadedToBucket,
UploadValidationExceptionFilter,
type UploadedFileInfo,
} from '@openbucket/nestjs/multer';

@Controller('files')
export class FilesController {
@Post()
@UseFilters(UploadValidationExceptionFilter) // rejected upload → HTTP 400
@UseInterceptors(
OpenBucketFileInterceptor('file', {
bucket: 'uploads',
key: 'uuid', // generate a safe, unique key
validate: {
maxBytes: 10 * 1024 * 1024, // 10 MiB
allowedContentTypes: ['image/*'], // sniffed, not trusted from the client
},
}),
)
upload(@UploadedToBucket() file: UploadedFileInfo) {
// `file` is already committed to the store:
// { bucket, key, url?, etag, size, contentType }
return { key: file.key, url: file.url };
}
}

That's the entire feature. Try it:

curl -F 'file=@./photo.jpg' http://localhost:3000/files
# → { "key": "a1b2c3…", "url": "http://localhost:3000/storage/uploads/a1b2c3…?X-Amz-…" }

A non-image, or anything over 10 MiB, comes back as a 400 with a structured error — no bytes stored.

Step 6 — Read it back

file.url is a short-lived presigned GET URL. Store the stable key in your database (not the signed URL), and mint a fresh URL whenever you serve the file:

import { OpenBucketService } from '@openbucket/nestjs';

// somewhere in a service:
const url = this.ob.presignGetUrl('uploads', key, {
baseUrl: 'http://localhost:3000/storage',
expiresIn: 3600, // 1 hour
});

Where did the file go?

Open http://localhost:3000/storage/admin, sign in with admin and your password, and you'll see the object in the bucket browser — with an inline preview. That's the payoff of an object store that lives inside your app: the same engine runs on your laptop and in production, and you didn't stand up a single extra service.

Where to go next

Not sure it fits your use case? Read the honest Is OpenBucket for you? guide — it's a single-node store by design, and we're upfront about when to reach for something else.


If this saved you an afternoon, a ⭐ on GitHub genuinely helps others find the project. Questions? Come say hi in Discussions.