NestboltNestbolt

@nestbolt/medialibrary

Storage Drivers

Configure local filesystem and AWS S3 storage backends, or implement a custom StorageDriver for any storage system.

The media library supports multiple storage backends through a driver-based architecture. Each named "disk" maps to a StorageDriver implementation. The built-in drivers are LocalDriver (filesystem) and S3Driver (AWS S3 and compatible services). You can also implement the StorageDriver interface for custom backends.

Local Driver

The local driver stores files on the server's filesystem. It is the simplest option and requires no additional dependencies.

Configuration

MediaModule.forRoot({
  defaultDisk: "local",
  disks: {
    local: {
      driver: "local",
      root: "./uploads",
      urlBase: "/media",
    },
  },
});

Options

OptionTypeDefaultDescription
driver"local"--Required. Must be "local".
rootstringprocess.cwd()Root directory for file storage. Relative paths are resolved from the current working directory.
urlBasestring""URL prefix for generating public URLs. For example, /media produces URLs like /media/<path>.

File Storage Layout

With the default path generator, files are stored as:

uploads/
  <media-uuid>/
    photo.jpg                     (original)
    conversions/
      thumbnail-photo.webp         (conversion)
      preview-photo.webp           (conversion)

Serving Static Files

The local driver generates URLs but does not serve files. You need to configure your web server or NestJS to serve the upload directory as static files.

With NestJS ServeStaticModule:

import { ServeStaticModule } from "@nestjs/serve-static";
import { join } from "path";

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(process.cwd(), "uploads"),
      serveRoot: "/media",
    }),
    MediaModule.forRoot({
      disks: {
        local: {
          driver: "local",
          root: "./uploads",
          urlBase: "/media",
        },
      },
    }),
  ],
})
export class AppModule {}

Or with Express static middleware:

import { NestFactory } from "@nestjs/core";
import * as express from "express";

const app = await NestFactory.create(AppModule);
app.use("/media", express.static("./uploads"));

Path Traversal Protection

The local driver includes built-in path traversal protection. Any path that resolves outside the configured root directory throws an error. This prevents directory traversal attacks via crafted file paths.

Temporary URLs

The local driver does not support temporary (presigned) URLs. Calling getTemporaryUrl() on media stored on a local disk throws an error. If you need temporary URLs, use the S3 driver.

S3 Driver

The S3 driver stores files on AWS S3 or any S3-compatible object storage service (MinIO, DigitalOcean Spaces, Cloudflare R2, Backblaze B2, etc.).

Prerequisites

npm install @aws-sdk/client-s3

For presigned temporary URLs:

npm install @aws-sdk/s3-request-presigner

Configuration

MediaModule.forRoot({
  defaultDisk: "s3",
  disks: {
    s3: {
      driver: "s3",
      bucket: "my-app-media",
      region: "us-east-1",
      prefix: "uploads/",
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
      },
    },
  },
});

Options

OptionTypeDefaultDescription
driver"s3"--Required. Must be "s3".
bucketstring--Required. The S3 bucket name.
regionstring"us-east-1"AWS region.
prefixstring""Key prefix prepended to all object keys. Trailing slashes are normalized.
credentialsobject--AWS credentials. If omitted, the default AWS credential chain is used.
credentials.accessKeyIdstring--AWS access key ID.
credentials.secretAccessKeystring--AWS secret access key.
credentials.sessionTokenstring--Optional session token for temporary credentials.
endpointstring--Custom S3 endpoint URL. Required for S3-compatible services.
forcePathStylebooleanfalseUse path-style URLs instead of virtual-hosted-style. Required for MinIO and some S3-compatible services.
clientS3Client--Pre-configured S3Client instance. When provided, all other connection options are ignored.

AWS Credential Chain

When credentials is omitted, the AWS SDK uses its default credential provider chain:

  1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
  2. Shared credentials file (~/.aws/credentials)
  3. EC2/ECS instance metadata (IAM role)
  4. SSO credentials

This means in production on AWS infrastructure (EC2, ECS, Lambda), you typically do not need to provide credentials explicitly.

URL Generation

The S3 driver generates public URLs in the format:

https://<bucket>.s3.<region>.amazonaws.com/<prefix><path>

When a custom endpoint is configured:

<endpoint>/<bucket>/<prefix><path>

Presigned Temporary URLs

Generate time-limited URLs for private S3 objects:

const media = await mediaService.getFirstMedia("Post", postId, "images");

// URL expires in 1 hour
const tempUrl = await mediaService.getTemporaryUrl(
  media,
  new Date(Date.now() + 60 * 60 * 1000),
);

// Conversion URL expires in 30 minutes
const tempThumbUrl = await mediaService.getTemporaryUrl(
  media,
  new Date(Date.now() + 30 * 60 * 1000),
  "thumbnail",
);

This requires @aws-sdk/s3-request-presigner. The expiration is specified as a Date object, and the SDK calculates the expiresIn duration automatically.

S3-Compatible Services

MinIO

disks: {
  minio: {
    driver: "s3",
    bucket: "my-bucket",
    region: "us-east-1",
    endpoint: "http://localhost:9000",
    forcePathStyle: true,
    credentials: {
      accessKeyId: "minioadmin",
      secretAccessKey: "minioadmin",
    },
  },
}

DigitalOcean Spaces

disks: {
  spaces: {
    driver: "s3",
    bucket: "my-space",
    region: "nyc3",
    endpoint: "https://nyc3.digitaloceanspaces.com",
    credentials: {
      accessKeyId: process.env.DO_SPACES_KEY,
      secretAccessKey: process.env.DO_SPACES_SECRET,
    },
  },
}

Cloudflare R2

disks: {
  r2: {
    driver: "s3",
    bucket: "my-bucket",
    region: "auto",
    endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY_ID,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
    },
  },
}

Pre-configured S3 Client

If you need full control over the S3 client configuration, pass a pre-configured S3Client instance:

import { S3Client } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: "us-east-1",
  maxAttempts: 3,
  requestHandler: customHandler,
});

MediaModule.forRoot({
  disks: {
    s3: {
      driver: "s3",
      bucket: "my-bucket",
      client: s3Client,
    },
  },
});

When client is provided, region, credentials, endpoint, and forcePathStyle are ignored.

Multiple Disks

You can define any number of disks and use different ones for different collections or individual uploads:

MediaModule.forRoot({
  defaultDisk: "local",
  disks: {
    local: {
      driver: "local",
      root: "./uploads",
      urlBase: "/media",
    },
    s3Public: {
      driver: "s3",
      bucket: "my-public-bucket",
      region: "us-east-1",
    },
    s3Private: {
      driver: "s3",
      bucket: "my-private-bucket",
      region: "us-east-1",
    },
  },
});

Then target specific disks:

// Via collection configuration
addCollection("profile-photos").useDisk("s3Public");
addCollection("private-docs").useDisk("s3Private");

// Via upload-time override
await mediaService
  .addMediaFromBuffer(buffer, "file.pdf")
  .forModel("User", userId)
  .toDisk("s3Private")
  .toMediaCollection("documents");

Custom Storage Driver

Implement the StorageDriver interface to support any storage backend:

import { StorageDriver } from "@nestbolt/medialibrary";

export class CloudinaryDriver implements StorageDriver {
  async put(path: string, data: Buffer): Promise<void> {
    // Upload data to the given path
  }

  async putStream(path: string, stream: NodeJS.ReadableStream): Promise<void> {
    // Upload stream to the given path
  }

  async get(path: string): Promise<Buffer> {
    // Read and return file contents as Buffer
  }

  getStream(path: string): NodeJS.ReadableStream {
    // Return a readable stream for the file
  }

  async delete(path: string): Promise<void> {
    // Delete a single file
  }

  async deleteDirectory(path: string): Promise<void> {
    // Delete all files under the given directory path
  }

  async exists(path: string): Promise<boolean> {
    // Check if a file exists
  }

  async copy(source: string, destination: string): Promise<void> {
    // Copy a file from source to destination
  }

  async move(source: string, destination: string): Promise<void> {
    // Move a file from source to destination
  }

  async size(path: string): Promise<number> {
    // Return the file size in bytes
  }

  async mimeType(path: string): Promise<string> {
    // Return the file's MIME type
  }

  url(path: string): string {
    // Return the public URL for the file
  }

  async temporaryUrl(
    path: string,
    expiration: Date,
    options?: Record<string, any>,
  ): Promise<string> {
    // Return a temporary/signed URL that expires at the given date
    // Throw if not supported
  }
}

StorageDriver Interface Reference

MethodReturn TypeDescription
put(path, data)Promise<void>Write a Buffer to storage
putStream(path, stream)Promise<void>Write a readable stream to storage
get(path)Promise<Buffer>Read file contents as a Buffer
getStream(path)ReadableStreamGet a readable stream for a file
delete(path)Promise<void>Delete a single file
deleteDirectory(path)Promise<void>Recursively delete a directory and its contents
exists(path)Promise<boolean>Check if a file exists
copy(source, dest)Promise<void>Copy a file
move(source, dest)Promise<void>Move a file
size(path)Promise<number>Get file size in bytes
mimeType(path)Promise<string>Detect or return the file's MIME type
url(path)stringGenerate a public URL for a file
temporaryUrl(path, exp, opts?)Promise<string>Generate a temporary signed URL

DiskManager

The DiskManager service resolves disk names to StorageDriver instances. It caches driver instances for reuse. You can inject it directly if needed:

import { DiskManager } from "@nestbolt/medialibrary";

@Injectable()
export class FileService {
  constructor(private readonly diskManager: DiskManager) {}

  async readFile(diskName: string, path: string): Promise<Buffer> {
    const driver = this.diskManager.disk(diskName);
    return driver.get(path);
  }

  async fileExists(diskName: string, path: string): Promise<boolean> {
    const driver = this.diskManager.disk(diskName);
    return driver.exists(path);
  }
}

If no disk name is provided, DiskManager uses the configured defaultDisk. If a disk name is requested that is not in the configuration, a DiskNotConfiguredException is thrown (unless the name is "local", in which case a default local driver using process.cwd() is created automatically).