Steven's Knowledge

Getting Started

Run MinIO locally, talk to it with the AWS CLI and Node SDK, upload and download

Getting Started

The fastest way to learn: MinIO, which speaks the S3 API, runs in Docker, and works with every S3 client. Patterns transfer to AWS S3, Cloudflare R2, GCS via interop, and everything else.

Run MinIO

# docker-compose.yml
services:
  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"      # S3 API
      - "9001:9001"      # Web console
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data

volumes:
  minio-data:
docker compose up -d
open http://localhost:9001       # console; log in as minioadmin / minioadmin

Talk to It with the AWS CLI

The AWS CLI works against any S3-compatible endpoint:

# Configure a profile that points at MinIO
aws configure --profile local
# AWS Access Key ID: minioadmin
# AWS Secret Access Key: minioadmin
# Default region: us-east-1
# Default output format: json

# Create a bucket
aws --endpoint-url http://localhost:9000 --profile local s3 mb s3://photos

# Upload a file
aws --endpoint-url http://localhost:9000 --profile local s3 cp ~/sunset.jpg s3://photos/2025/sunset.jpg

# List
aws --endpoint-url http://localhost:9000 --profile local s3 ls s3://photos/2025/

# Download
aws --endpoint-url http://localhost:9000 --profile local s3 cp s3://photos/2025/sunset.jpg ./out.jpg

# Sync a folder
aws --endpoint-url http://localhost:9000 --profile local s3 sync ./public s3://photos/static/

Drop the --endpoint-url and that exact same CLI talks to AWS S3.

Talk to It from Node

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
  endpoint: 'http://localhost:9000',   // omit for AWS S3
  region: 'us-east-1',
  credentials: {
    accessKeyId: 'minioadmin',
    secretAccessKey: 'minioadmin',
  },
  forcePathStyle: true,                // required for MinIO
});

// Upload
await s3.send(new PutObjectCommand({
  Bucket: 'photos',
  Key: '2025/sunset.jpg',
  Body: imageBuffer,
  ContentType: 'image/jpeg',
  CacheControl: 'public, max-age=31536000, immutable',
  Metadata: { 'uploaded-by': 'user-42' },
}));

// Download
const result = await s3.send(new GetObjectCommand({
  Bucket: 'photos',
  Key: '2025/sunset.jpg',
}));
const bytes = await result.Body.transformToByteArray();

The same SDK against AWS S3, R2, GCS interop, Wasabi, B2 — change endpoint + credentials.

Presigned URLs (the Killer Pattern)

You almost never want users to upload via your server. Presigned URLs let them upload directly to storage with a time-limited token your backend signs:

// Backend issues a presigned URL
const url = await getSignedUrl(
  s3,
  new PutObjectCommand({
    Bucket: 'photos',
    Key: `uploads/${userId}/${uuid()}.jpg`,
    ContentType: 'image/jpeg',
  }),
  { expiresIn: 300 },        // 5 minutes
);

// Send url to the browser
return Response.json({ url });

Browser:

await fetch(url, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/jpeg' },
  body: imageBlob,
});

The bytes go directly browser → storage. Your server never touches them — scales infinitely, doesn't tie up your application threads.

Anatomy of an Object

When you upload, you set:

FieldExampleUsed for
BucketphotosWhere it lives
Key2025/sunset.jpgThe path-like identifier
Body<bytes>The actual content
ContentTypeimage/jpegWhat browsers see
CacheControlpublic, max-age=31536000CDN / browser caching
ContentEncodinggzipPre-compressed content
ContentDispositionattachment; filename="..."Download vs inline
Metadata{ user: "42" }Arbitrary user-defined key/value
ACLprivate / public-readLegacy permission model
ServerSideEncryptionAES256 / aws:kmsEncryption at rest
StorageClassSTANDARD / GLACIERTier (cost/access trade-off)

Set Cache-Control and Content-Type correctly at upload time — your CDN and browsers respect them, you save bandwidth and improve UX.

The Core CLI Verbs

# Bucket-level
aws s3 mb s3://my-bucket                 # make bucket
aws s3 rb s3://my-bucket --force         # remove bucket (and contents)
aws s3 ls                                # list buckets
aws s3 ls s3://my-bucket/                # list contents

# Object-level
aws s3 cp ./file s3://bucket/key         # copy file → object
aws s3 cp s3://bucket/key ./file         # copy object → file
aws s3 cp s3://a/k1 s3://b/k2            # copy object → object (within or across)
aws s3 mv ...                            # move (cp + delete)
aws s3 rm s3://bucket/key                # delete
aws s3 sync ./local s3://bucket/path     # recursive, only-changed

# Useful flags
--storage-class GLACIER
--cache-control "public, max-age=31536000"
--content-type "image/jpeg"
--metadata user=42
--dryrun
--exclude "*.tmp" --include "*.jpg"

aws s3 sync is the everyday workhorse — uploads only what changed.

Listing and Pagination

aws s3api list-objects-v2 --bucket photos --prefix 2025/ --max-keys 100

Returns up to 1,000 objects per call with a NextContinuationToken. Most SDKs have paginators:

const paginator = paginateListObjectsV2(
  { client: s3 },
  { Bucket: 'photos', Prefix: '2025/' }
);

for await (const page of paginator) {
  for (const obj of page.Contents ?? []) {
    console.log(obj.Key, obj.Size);
  }
}

Never assume one call returns everything.

Tear Down

docker compose down -v

What's Next

You can upload, download, and issue presigned URLs. Next:

  • Patterns — direct upload UX, multipart, lifecycle, versioning, static hosting, replication
  • Best Practices — security, cost, observability, naming, pitfalls

On this page