Upload files to MinIO with cURL and presigned PUT URLs
To export a local file to MinIO, generate a presigned PUT URL for one bucket and object key, then
send the file with curl --upload-file. This walkthrough builds a local server, creates the bucket,
and downloads the uploaded object to check that its bytes match your file.
Understand what the URL authorizes
MinIO exposes an S3-compatible HTTP API. An SDK signs a request with your access and secret keys;
cURL sends the bytes using that signature. The SDK and the mc command-line client are tools, not
separate authentication methods.
A presigned URL is a bearer credential. Anyone holding this example’s URL can PUT to its exact object key for 10 minutes, including replacing it with different bytes. It does not authenticate the contents of a particular local file. Keep it out of shell history, logs, and shared messages. A POST-policy form upload is a different operation; use the SDK’s presigned PUT operation.
Prepare a local demo
The MinIO community repository was archived on April 25, 2026, and states that it is no longer maintained. The pinned source build below is for local compatibility testing, not a recommendation for a new production deployment. Use a maintained service for production and follow its authentication and TLS requirements.
Prerequisites
Use Linux with Bash, cURL, Go 1.26.8, Node.js 24.15.0, and Corepack with Yarn 4.12.0. The examples
use Node’s built-in TypeScript support;
.mts files run as ES modules even inside a CommonJS parent project. The server pin is
RELEASE.2025-10-15T17-29-55Z, and the JavaScript SDK pin is
minio@8.0.7. Building the server requires network access, disk space for Go dependencies, and a
few minutes of compilation.
Start from a writable directory. The following block creates a new minio-curl-demo directory and
installs its own SDK. It refuses an existing directory. Its lockfile makes this a separate Yarn
project. Its package
and cache settings keep the installation local to that directory. The parent shell stays in its
original directory.
(
mkdir minio-curl-demo &&
cd minio-curl-demo &&
printf '%s\n' '{"name":"minio-curl-demo","private":true,"packageManager":"yarn@4.12.0","dependencies":{"minio":"8.0.7"}}' > package.json &&
touch yarn.lock &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
env COREPACK_HOME="$PWD/.corepack" YARN_IGNORE_PATH=1 \
YARN_GLOBAL_FOLDER="$PWD/.yarn/global" corepack yarn install
)
Initial setup
Build the pinned server source
into the demo’s bin directory. Run this from the same parent directory as the previous block.
If compilation fails, stop here; do not start a leftover binary from an earlier build.
(
cd minio-curl-demo &&
mkdir bin data go-path go-cache tmp &&
env GOENV=off GOWORK=off GOTOOLCHAIN=local \
GOPATH="$PWD/go-path" GOCACHE="$PWD/go-cache" GOTMPDIR="$PWD/tmp" \
GOBIN="$PWD/bin" go install github.com/minio/minio@RELEASE.2025-10-15T17-29-55Z
)
Open two Bash terminals in that parent directory. Paste these settings into both terminals. Choose two unused ports if these are occupied, using the same values in both terminals. The credentials are deliberately public demo values; use them only with this loopback-only server.
export MINIO_API_PORT=19000
export MINIO_CONSOLE_PORT=19001
export MINIO_ENDPOINT="http://127.0.0.1:${MINIO_API_PORT}"
export MINIO_ACCESS_KEY='curl-demo-admin'
export MINIO_SECRET_KEY='local-demo-only-password'
In the first terminal, start the server in the foreground. Leave it running while you use the
second terminal. Press Ctrl+C in the first terminal to stop it when finished; the shell continues
and the objects remain in minio-curl-demo/data.
(
cd minio-curl-demo &&
MINIO_ROOT_USER="$MINIO_ACCESS_KEY" MINIO_ROOT_PASSWORD="$MINIO_SECRET_KEY" \
./bin/minio server ./data --address "127.0.0.1:${MINIO_API_PORT}" \
--console-address "127.0.0.1:${MINIO_CONSOLE_PORT}"
)
Create the bucket and signer
Save each of the following TypeScript files inside minio-curl-demo. The shared client checks
that the endpoint is an origin, without a path prefix or embedded credentials. Its region matches
this local server. For an existing deployment, use its actual region and bucket-scoped credentials;
the root credentials here are only for provisioning the private demo.
Save as minio-client.mts:
import { Client } from 'minio'
export function createClient(): Client {
const { MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY } = process.env
if (!MINIO_ENDPOINT || !MINIO_ACCESS_KEY || !MINIO_SECRET_KEY) {
throw new Error('Set the endpoint and credentials')
}
const endpoint = new URL(MINIO_ENDPOINT)
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username ||
endpoint.password || endpoint.pathname !== '/' || endpoint.search || endpoint.hash) {
throw new Error('Use an HTTP or HTTPS origin without credentials or a path prefix')
}
return new Client({
endPoint: endpoint.hostname,
port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)),
useSSL: endpoint.protocol === 'https:',
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
region: 'us-east-1',
})
}
Save as prepare-bucket.mts. Signing a URL does not create a bucket; this step does.
import { createClient } from './minio-client.mts'
async function main(): Promise<void> {
const [bucket, ...extra] = process.argv.slice(2)
if (!bucket || extra.length) throw new Error('Usage: node prepare-bucket.mts BUCKET')
const client = createClient()
if (!(await client.bucketExists(bucket))) await client.makeBucket(bucket, 'us-east-1')
console.log(`Bucket ready: ${bucket}`)
}
main().catch(() => {
console.error('Bucket setup failed; check the server, credentials, and bucket name')
process.exitCode = 1
})
In the second terminal, create the bucket. A successful run prints Bucket ready: curl-demo.
If the server is not ready, wait for its startup message and rerun this command.
(cd minio-curl-demo && node prepare-bucket.mts curl-demo)
Generate pre-signed URLs
Save as presign.mts. It signs either PUT or GET and emits a cURL configuration line, which the
upload and verification commands pass through standard input. Both operations use a 600-second
expiry. The SDK API reference
documents these two distinct methods.
import { createClient } from './minio-client.mts'
async function main(): Promise<void> {
const [method, bucket, key, ...extra] = process.argv.slice(2)
if (!bucket || !key || extra.length || !['put', 'get'].includes(method ?? '')) {
throw new Error('Usage: node presign.mts put|get BUCKET KEY')
}
const client = createClient()
const url = method === 'put'
? await client.presignedPutObject(bucket, key, 600)
: await client.presignedGetObject(bucket, key, 600)
console.log(`url = ${JSON.stringify(url)}`)
}
main().catch(() => {
console.error('Signing failed; check the method, bucket, key, endpoint, and credentials')
process.exitCode = 1
})
Do not run the signer on its own in a recorded terminal: its output contains the presigned URL. The SDK encodes the object key. Pass the original key to the signer and leave the resulting URL unchanged, including its host, port, path, and query string.
Upload a file and log the result
Save as upload.sh inside the demo directory. Run it with Bash; do not source it. It accepts a
regular local file, a bucket, and an explicit object key. Empty files are valid. Prefix filenames
such as - or -report.bin with ./ so they identify files rather than cURL’s standard input.
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 3 ] || [ ! -f "$1" ] || [ ! -r "$1" ] || [ "$1" = '-' ]; then
printf 'Usage: bash upload.sh FILE BUCKET KEY (readable regular file)\n' >&2
exit 1
fi
file=$1
bucket=$2
key=$3
configuration=$(node presign.mts put "$bucket" "$key") || exit 1
if printf '%s\n' "$configuration" |
curl -q --config - --globoff --path-as-is --fail --silent \
--connect-timeout 5 --max-time 120 --upload-file "$file" \
--output /dev/null --write-out 'HTTP %{http_code}\n' 2>/dev/null |
tee -a upload.log; then
printf 'Uploaded %s to %s/%s\n' "$file" "$bucket" "$key"
else
printf 'Upload or status logging failed for %s; verify the object before retrying\n' "$file" >&2
exit 1
fi
-q is cURL’s first option so it ignores a caller’s .curlrc. --config - reads the URL from
standard input, keeping it out of cURL’s process arguments. --globoff keeps brackets and braces in
filenames literal. --fail makes an HTTP rejection fail the transfer, and pipefail preserves
transfer or logging failures through the pipeline. The assignment checks the signer separately.
The script suppresses raw transfer diagnostics and logs only the HTTP status, not the response body
or URL. See the
cURL manual for configuration syntax and options.
Run this from the parent directory in the second terminal. It creates or replaces the local demo
file sample.txt and uploads it under a key containing spaces and a plus sign:
(
cd minio-curl-demo &&
printf 'hello, MinIO\n' > sample.txt &&
bash upload.sh sample.txt curl-demo 'exports/sample + 1.txt'
)
Expect HTTP 200 followed by Uploaded sample.txt to curl-demo/exports/sample + 1.txt.
The bucket is unversioned: a successful PUT to an existing key replaces its previous bytes.
Choose a new key if you want to keep the old object. An expired URL, bad signature, or missing
bucket must fail without the Uploaded message.
Download and compare the stored bytes
An HTTP 200 proves the server accepted the PUT. Check the stored object separately with a signed
GET. This block writes downloaded.txt, replacing that local file if the download proceeds, then
compares it with sample.txt. A successful comparison prints Verified identical bytes.
(
set -euo pipefail
cd minio-curl-demo || exit 1
configuration=$(node presign.mts get curl-demo 'exports/sample + 1.txt') || exit 1
if printf '%s\n' "$configuration" |
curl -q --config - --globoff --path-as-is --fail --silent \
--connect-timeout 5 --max-time 120 --output downloaded.txt 2>/dev/null; then
cmp sample.txt downloaded.txt && printf 'Verified identical bytes\n'
else
printf 'Download failed; check the object and signing configuration\n' >&2
exit 1
fi
)
Diagnose a failed upload
If signing fails, check the environment settings and all three script arguments. An endpoint with
a proxy path prefix, such as https://storage.example.com/minio/, is rejected. Use the service’s
S3 API origin, and use HTTPS for a remote endpoint.
HTTP 000 means no HTTP response was obtained. Check that the server is running on the configured
port, that the file is readable, and that the certificate is trusted. Do not disable certificate
validation to make a remote upload pass. HTTP 403 can indicate an invalid or expired signature,
wrong credentials, denied permissions, or a clock mismatch. HTTP 404 can indicate a missing
bucket; the signer can produce a URL for a bucket that does not exist.
A logging failure can happen after the server stored the object. For example, upload.log
might be a directory or unwritable. The script reports failure because it could not record the
status; check with GET before deciding to retry. A disconnected client can also leave the outcome
uncertain. Reusing the same key may replace an object that was already accepted.
Choose the next step
This script sends one file in one PUT, with a 120-second transfer deadline. It does not implement multipart uploads, resumable transfers, or directory synchronization. For those tasks, use an SDK or a storage client with the required behavior and check its replacement policy. For an AWS-specific batch workflow, see batch exports to Amazon S3 with cURL and the AWS CLI.
