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 / minioadminTalk 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-presignerimport { 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:
| Field | Example | Used for |
|---|---|---|
Bucket | photos | Where it lives |
Key | 2025/sunset.jpg | The path-like identifier |
Body | <bytes> | The actual content |
ContentType | image/jpeg | What browsers see |
CacheControl | public, max-age=31536000 | CDN / browser caching |
ContentEncoding | gzip | Pre-compressed content |
ContentDisposition | attachment; filename="..." | Download vs inline |
Metadata | { user: "42" } | Arbitrary user-defined key/value |
ACL | private / public-read | Legacy permission model |
ServerSideEncryption | AES256 / aws:kms | Encryption at rest |
StorageClass | STANDARD / GLACIER | Tier (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 100Returns 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 -vWhat'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