minio-imgproxy
v1.4.0
Published
Lightweight TypeScript library to upload files to MinIO (or any S3-compatible storage) and generate secure imgproxy URLs.
Maintainers
Readme
minio-imgproxy
Upload files to MinIO (or any S3-compatible storage) and get back a signed imgproxy URL — in one call.
- HMAC-SHA256 signed URLs — prevents URL tampering and resize abuse
- Magic-byte validation — catches renamed files (
.exe → .jpg) - MinIO client cached per config — no reconnect on every request
- Secrets never leave the server — browser only receives the final URL
minio-imgproxy/serversubpath for Next.js — build-time error if imported client-side
How it works
Browser → POST file → Your server (NestJS / Next.js Route Handler)
│
validate + upload to MinIO
sign imgproxy URL with HMAC
│
return { url } only ←────── secrets stay here
│
Browser → GET https://imgproxy.yourdomain.com/<sig>/rs:fit:800:0:0/plain/s3://bucket/key.jpgThe browser never sees your MinIO credentials or imgproxy signing keys — only the final signed URL.
Prerequisites
Three services need to be running:
| Service | Purpose | |---|---| | MinIO (or S3 / R2) | Stores original files | | imgproxy | Resizes and delivers images on-demand | | Node.js 18+ | Runs your app |
Generate signing keys (run once, save both values):
echo "IMGPROXY_KEY=$(openssl rand -hex 32)"
echo "IMGPROXY_SALT=$(openssl rand -hex 32)"Start imgproxy with those keys:
docker run -p 8080:8080 \
-e IMGPROXY_KEY="your64hexkey" \
-e IMGPROXY_SALT="your64hexsalt" \
-e IMGPROXY_USE_S3=true \
-e AWS_ACCESS_KEY_ID="minio-access-key" \
-e AWS_SECRET_ACCESS_KEY="minio-secret-key" \
-e AWS_S3_ENDPOINT_URL="http://minio:9000" \
darthsim/imgproxyInstallation
npm install minio-imgproxy minioEnvironment variables
These go in your server only (NestJS .env, Next.js server env — never NEXT_PUBLIC_*):
MINIO_ENDPOINT=https://minio.yourdomain.com
MINIO_BUCKET=media
MINIO_ACCESS_KEY=your-access-key
MINIO_SECRET_KEY=your-secret-key
MINIO_REGION=us-east-1
MEDIA_BASE_URL=https://imgproxy.yourdomain.com
IMGPROXY_KEY=your64hexkey
IMGPROXY_SALT=your64hexsaltRecommended: NestJS + Next.js monorepo
The best setup is to put this library only in NestJS. Next.js proxies the file to NestJS and returns only the URL to the browser.
Browser → Next.js Route Handler → NestJS upload endpoint → MinIO
↓
returns { url }
↓
Next.js → Browser (URL only)NestJS — env vars and secrets live here only
// media/media.service.ts
import { Injectable, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MediaUploadClient, validateImageBuffer, type ImageSize } from "minio-imgproxy";
@Injectable()
export class MediaService {
private readonly media: MediaUploadClient;
constructor(config: ConfigService) {
this.media = new MediaUploadClient({
endpoint: config.getOrThrow("MINIO_ENDPOINT"),
bucket: config.getOrThrow("MINIO_BUCKET"),
accessKey: config.getOrThrow("MINIO_ACCESS_KEY"),
secretKey: config.getOrThrow("MINIO_SECRET_KEY"),
mediaBaseUrl: config.getOrThrow("MEDIA_BASE_URL"),
imgproxyKey: config.get("IMGPROXY_KEY"),
imgproxySalt: config.get("IMGPROXY_SALT"),
});
}
async upload(buffer: Buffer, mimeType: string, folder = "uploads") {
const check = validateImageBuffer(buffer, mimeType);
if (!check.ok) throw new BadRequestException(check.error);
return this.media.upload(buffer, mimeType, folder);
}
getUrl(objectKey: string, size: ImageSize = "medium") {
return this.media.getUrl(objectKey, size);
}
async delete(objectKey: string) {
return this.media.delete(objectKey);
}
}// media/media.controller.ts
import { Controller, Post, Delete, Param, UploadedFile, UseInterceptors, BadRequestException, UseGuards } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { MediaService } from "./media.service";
@Controller("media")
@UseGuards(JwtAuthGuard)
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@Post("upload")
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 8 * 1024 * 1024 } }))
async upload(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException("No file provided");
return this.mediaService.upload(file.buffer, file.mimetype, "uploads");
// returns { objectKey, url } — url is the only thing the browser needs
}
@Delete(":key(*)")
async delete(@Param("key") key: string) {
await this.mediaService.delete(key);
return { deleted: true };
}
}Next.js — thin proxy, zero secrets
# Next.js .env — only needs to know where NestJS is
NEST_API_URL=http://localhost:3001// app/api/upload/route.ts
// No minio-imgproxy import here — this file has no secrets
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
const upstream = new FormData();
upstream.append("file", file);
const res = await fetch(`${process.env.NEST_API_URL}/media/upload`, {
method: "POST",
headers: { Authorization: req.headers.get("Authorization") ?? "" },
body: upstream,
});
return Response.json(await res.json());
}// components/Uploader.tsx ("use client")
"use client";
export function Uploader() {
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
const { url } = await res.json();
console.log(url); // safe imgproxy URL — no secrets
}
return <input type="file" accept="image/*" onChange={handleUpload} />;
}Next.js only (without NestJS)
If you're not using NestJS, import from minio-imgproxy/server to get a build-time error if the module is ever accidentally bundled into a client component.
npm install server-only # one-time// app/api/upload/route.ts
import { validateImageBuffer, uploadFile } from "minio-imgproxy/server";
export async function POST(req: Request) {
const form = await req.formData();
const file = form.get("file") as File;
const buffer = Buffer.from(await file.arrayBuffer());
const check = validateImageBuffer(buffer, file.type);
if (!check.ok) return Response.json({ error: check.error }, { status: check.status });
const { url } = await uploadFile(buffer, file.type, "uploads");
return Response.json({ url });
}// lib/media.ts (server-side singleton)
import { MediaUploadClient, loadConfigFromEnv } from "minio-imgproxy/server";
export const media = new MediaUploadClient(loadConfigFromEnv());NestJS only
import { MediaUploadClient, validateImageBuffer, loadConfigFromEnv } from "minio-imgproxy";
const media = new MediaUploadClient(loadConfigFromEnv());
const check = validateImageBuffer(buffer, "image/jpeg");
if (!check.ok) throw new Error(check.error);
const { objectKey, url } = await media.upload(buffer, "image/jpeg", "avatars");API reference
How sizes work
There is one file stored in MinIO. imgproxy generates every size on-demand from that one original — nothing is pre-resized or duplicated in storage. Each size is just a different signed URL pointing to the same object key.
MinIO: avatars/uuid.jpg (one file, the original)
│
├─ thumbnail URL → imgproxy resizes to 150×150 on first request
├─ medium URL → imgproxy resizes to 600px wide on first request
├─ large URL → imgproxy resizes to 1200px wide on first request
└─ original URL → imgproxy serves as-is, no resizeUpload — returns all sizes immediately
const { objectKey, urls } = await uploadFile(buffer, "image/jpeg", "avatars");
urls.thumbnail // → signed URL, 150×150 square crop
urls.medium // → signed URL, 600px wide (height auto)
urls.large // → signed URL, 1200px wide (height auto)
urls.original // → signed URL, no resize
// urls.medium is also available as the top-level url field (backward compat)interface UploadUrls {
thumbnail: string;
medium: string;
large: string;
original: string;
}
interface UploadResult {
objectKey: string; // "avatars/1716382103-uuid.jpg" — save this to your DB
url: string; // same as urls.medium
urls: UploadUrls; // all preset sizes, ready to use
}Get URLs later from a stored objectKey
// Single size
getUrl(objectKey, "thumbnail") // → 150×150
getUrl(objectKey, "medium") // → 600px wide
getUrl(objectKey, "large") // → 1200px wide
getUrl(objectKey, "original") // → no resize
// All sizes at once
const urls = getAllUrls(objectKey);
// { thumbnail, medium, large, original }
// Custom transform
getCustomUrl(objectKey, { width: 400, height: 300, fit: "fill", format: "webp", quality: 85 })The preset sizes:
| Size | Width | Height | Fit | Use case |
|---|---|---|---|---|
| thumbnail | 150 | 150 | fill | Grid thumbnails, avatars |
| medium | 600 | auto | fit | Article images, cards |
| large | 1200 | auto | fit | Hero images, lightbox |
| original | — | — | — | Download, full-res view |
Upload functions
uploadFile(file: Buffer | File, contentType?: string, folder?: string): Promise<UploadResult>
uploadFileWithConfig(config, file, contentType?, folder?): Promise<UploadResult>
uploadBufferToObjectKey(objectKey, buffer, contentType, config?): Promise<UploadResult>URL functions (no network call)
getUrl(objectKey, size?: ImageSize): string
getAllUrls(objectKey): UploadUrls
getAllUrlsWithConfig(config, objectKey): UploadUrls
getCustomUrl(objectKey, transform: ImageTransform): string
getCustomUrlWithConfig(config, objectKey, transform): stringDelete
deleteFile(objectKey: string): Promise<void>
deleteFileWithConfig(config, objectKey): Promise<void>Validation
// For Buffer (server-side, recommended)
validateImageBuffer(buffer: Buffer, mimeType: string): ValidationResult
// For Web API File (async — reads magic bytes from the file header)
validateImageFile(file: File): Promise<ValidationResult>
type ValidationResult =
| { ok: true; extension: string }
| { ok: false; error: string; status: number }Allowed types: image/jpeg, image/png, image/webp — max 8 MB.
MediaUploadClient
const client = new MediaUploadClient(config: MediaUploadConfig);
client.upload(file, contentType?, folder?) → Promise<UploadResult> // includes .urls
client.uploadBuffer(objectKey, buffer, type) → Promise<UploadResult>
client.delete(objectKey) → Promise<void>
client.getUrl(objectKey, size?) → string
client.getAllUrls(objectKey) → UploadUrls
client.getCustomUrl(objectKey, transform) → stringImageTransform
interface ImageTransform {
width?: number;
height?: number;
fit?: "fit" | "fill" | "crop" | "pad" | "auto";
quality?: number; // 1–100
blur?: number;
format?: "webp" | "avif" | "png" | "jpg";
}// Examples
{ width: 1200, height: 630, fit: "fill", format: "webp", quality: 80 } // OG image
{ width: 200, height: 200, fit: "fill" } // avatar
{ width: 20, height: 20, fit: "fill", blur: 5 } // LQIP placeholderSecurity notes
- Secrets never reach the browser —
accessKey,secretKey,imgproxyKey,imgproxySaltare used only server-side.JSON.stringify(config)returns[REDACTED]for all secret fields. - HMAC-SHA256 URL signing — any attempt to modify a signed URL (change dimensions, object key, format) is rejected by imgproxy with
403. - Magic byte validation — file content is checked against known byte signatures, not just the
Content-Typeheader. A renamed executable is rejected. - SVG not supported — SVGs can contain inline scripts and are an XSS vector when served inline.
- Insecure mode — if
IMGPROXY_KEY/IMGPROXY_SALTare not set, the library logs a warning and generates/insecure/URLs. Anyone can then request arbitrary transformations from your imgproxy instance. Always set signing keys in production.
License
MIT
