Exportar archivos por lotes a Amazon S3 con curl y AWS CLI
En muchos flujos de trabajo de desarrollo, exportar archivos directamente a un almacenamiento en la
nube como Amazon S3 es un requisito habitual. Automatizar este proceso de file export reduce los errores
y acelera tu flujo de trabajo. En esta guía explicamos cómo exportar archivos por lotes a Amazon S3
generando pre-signed URL con AWS CLI y subiéndolos mediante curl en un script de Bash, lo que ofrece un
enfoque open source.
Requisitos previos
Antes de empezar, necesitas:
- AWS CLI versión 2 instalado y configurado con las credenciales adecuadas.
curlinstalado en tu sistema.- Node.js 24 o posterior, que ejecuta archivos TypeScript directamente. El firmador de más abajo lo necesita.
- Un entorno de shell Bash.
- Un bucket de S3 existente con los permisos necesarios.
- Un usuario o rol de IAM que pueda realizar acciones de S3.
Instala AWS CLI v2 (Linux):
# For x86_64 systems
curl -fsSL --retry 3 "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
# For arm64 systems
curl -fsSL --retry 3 "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o awscliv2.zip
unzip awscliv2.zip
sudo ./aws/install
rm -rf awscliv2.zip aws/ # Clean up: aws/ is a directory, so -f alone will not remove it
aws --version # Should output aws-cli/2.x.x ...
Configura AWS CLI. El firmador de la siguiente sección lee el mismo archivo de credenciales, por lo
que este único paso cubre ambas herramientas:
aws configure
# Enter:
# - AWS Access Key ID [None]: your_access_key
# - AWS Secret Access Key [None]: your_secret_key
# - Default region name [None]: your-region (for example, us-east-1)
# - Default output format [None]: json (or leave blank)
Instala los dos paquetes del AWS SDK que importa el firmador:
mkdir s3-batch-export && cd s3-batch-export
npm init -y
npm pkg set type=module
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Comprende los componentes
Amazon S3 es un almacén de objetos escalable. Una pre-signed URL otorga un permiso de duración limitada
para realizar un tipo específico de solicitud a S3 sin exponer tus credenciales de AWS a quien la
utilice. La firma cubre el método HTTP, el bucket y la clave, la expiración y cualquier encabezado
que decidas firmar, por lo que una URL firmada para un GET no se puede reutilizar como PUT.
No es un token de un solo uso. La misma URL se puede canjear tantas veces como quieras hasta que
expire, y cada canje sobrescribe el objeto que hay en esa clave. Trátala como una capacidad con fecha
límite: entrégala a una sola parte, mantenla fuera de los logs y de los referrers, y mantén --expires-in
con un valor bajo.
Ese detalle decide las herramientas. El comando aws s3 presign está documentado como exclusivo para GET:
«Esto permite que cualquiera que reciba la URL prefirmada recupere el objeto de S3 con una solicitud
HTTP GET». No tiene un indicador --method, por lo que su salida no puede autorizar curl -T; S3 responde
a ese intento con SignatureDoesNotMatch. Para firmar un PUT necesitas un firmador que te deje indicar la
operación, que es lo que hace @aws-sdk/s3-request-presigner.
AWS CLI sigue teniendo su sitio aquí para la configuración y la verificación de credenciales, y curl
sigue encargándose de la transferencia.
Concede los permisos de IAM necesarios
La firma en sí se calcula localmente, así que no se comprueba ningún permiso mientras se genera la URL. Los permisos que importan son los del propio firmador, porque S3 evalúa la política de la identidad que firma cuando se canjea la URL prefirmada. Adjunta una política como esta al usuario o rol de IAM cuyas credenciales configuraste:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
s3:GetObject es necesario si piensas prefirmar descargas, pero para el escenario de subida que se
describe aquí solo se requiere s3:PutObject. Una URL prefirmada nunca puede otorgar más de lo que el
firmador ya tiene, así que restringir esta política restringe todas las URL que repartas.
Sustituye your-bucket-name por el nombre real de tu bucket.
Genera URL PUT prefirmadas con el AWS SDK
Guarda esto como presign-put.ts. Imprime una URL y termina, lo que facilita llamarlo más adelante desde
un bucle de shell:
// presign-put.ts - print a pre-signed URL that authorizes PUT, and only PUT.
import { parseArgs } from 'node:util'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const { values } = parseArgs({
options: {
bucket: { type: 'string' },
key: { type: 'string' },
region: { type: 'string' },
'content-type': { type: 'string', default: 'application/octet-stream' },
'expires-in': { type: 'string', default: '3600' },
},
})
const { bucket, key, region } = values
if (!bucket || !key || !region) {
throw new Error('Usage: node presign-put.ts --bucket B --key K --region R [--content-type T]')
}
const expiresIn = Number(values['expires-in'])
// SigV4 caps a pre-signed URL at 7 days. Reject longer values here rather than
// relying on the SDK's generic expiry error; fractional values are rejected too.
if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
throw new Error(`--expires-in must be between 1 and 604800 seconds, got: ${values['expires-in']}`)
}
const client = new S3Client({
region,
// Default 'WHEN_SUPPORTED' hoists a CRC32 of an *empty* body into the query
// string. S3 enforces it against the bytes curl actually sends, so every
// upload would fail. Pre-signed PUTs must opt out.
requestChecksumCalculation: 'WHEN_REQUIRED',
})
const url = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: values['content-type'] }),
{
expiresIn,
// Pin Content-Type into the signature. S3 honors whatever Content-Type the
// uploader sends either way; signing it is what stops the uploader from
// sending a *different* one, since any mismatch now fails the signature.
signableHeaders: new Set(['content-type']),
},
)
console.log(url)
Ejecútalo:
node presign-put.ts \
--bucket your-bucket-name \
--key object-key \
--region your-region \
--content-type text/plain \
--expires-in 3600
Sustituye your-bucket-name, object-key (el nombre deseado del archivo en S3) y your-region
por tus valores concretos. La URL que se devuelve lleva la firma y su expiración en la cadena de
consulta; no contiene ninguna clave secreta, pero quien la tenga puede escribir ese único objeto, de
forma repetida, hasta que expire, así que mantenla fuera de los logs.
--expires-in es un límite máximo, no una garantía. Si firmas con credenciales temporales, procedentes de
un rol de IAM, de una sesión de SSO o de sts:AssumeRole, la URL deja de funcionar en el momento en que esas
credenciales expiran, lo que suele ocurrir bastante antes de la expiración que pediste.
X-Amz-SignedHeaders en la URL generada indica content-type;host. Esa es la lista que curl debe
reproducir exactamente. Enviar un Content-Type distinto, u omitir el encabezado, invalida la firma. La
expiración máxima de SigV4 es de 7 días (604.800 segundos).
Sube archivos con curl
Usa curl con el indicador -T para especificar el archivo local que quieres subir. Añadir --retry
hace que la subida sea más resistente a problemas de red transitorios. El encabezado Content-Type no es
opcional aquí: es uno de los encabezados firmados, por lo que debe coincidir con el --content-type que
pasaste al firmador. El subshell mantiene el manejo estricto de errores acotado a este ejemplo,
incluso si lo pegas en una sesión de Bash ya existente.
(
set -euo pipefail
# Determine the MIME type dynamically (works on Linux and macOS)
CONTENT_TYPE=$(file -b --mime-type localfile.txt)
# Sign for that exact type, then send that exact type
URL=$(node presign-put.ts \
--bucket your-bucket-name \
--key localfile.txt \
--region your-region \
--content-type "$CONTENT_TYPE")
if status=$(curl -fsS --retry 3 --retry-delay 2 \
-T localfile.txt \
-H "Content-Type: $CONTENT_TYPE" \
-o /dev/null -w '%{http_code}' \
"$URL") && [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo 'Upload completed.'
else
echo 'Upload failed: expected an HTTP 2xx response.' >&2
exit 1
fi
)
Sustituye localfile.txt por la ruta de tu archivo, y el bucket y la región por tus propios valores. -f
hace que curl termine con un código distinto de cero ante un error HTTP, en lugar de imprimir el
cuerpo de error XML de S3 como si fuera un éxito, y -sS mantiene silenciado el medidor de progreso
sin dejar de mostrar los errores reales. La comprobación explícita de 2xx también rechaza las
redirecciones, que -f por sí solo no trata como errores. No añadas -L: seguir una
redirección reenviaría el cuerpo a una URL que la firma no cubre.
Automatiza las exportaciones de archivos por lotes
Subir decenas de archivos a mano es tedioso. El siguiente script recorre un directorio, firma un PUT
para cada archivo, lo sube con curl y termina con un código distinto de cero si falló algún
archivo, de modo que un cron job o un paso de CI se entere de verdad.
#!/usr/bin/env bash
set -euo pipefail # Exit on error, undefined variable, or pipe failure
BUCKET="your-bucket-name"
EXPIRE=3600 # URL validity in seconds (1 hour)
FILES_DIR="/path/to/your/files" # Directory containing files to upload
REGION="your-region" # Your S3 bucket region
PRESIGN="./presign-put.ts" # The signer from the previous section
# --- pre-flight checks ---
if [[ ! -d "$FILES_DIR" ]]; then
echo "Error: Directory '$FILES_DIR' does not exist." >&2
exit 1
fi
if [[ ! -f "$PRESIGN" ]]; then
echo "Error: Signer '$PRESIGN' not found. See the previous section." >&2
exit 1
fi
if ! command -v aws &>/dev/null; then
echo "Error: AWS CLI command not found. Please install AWS CLI v2." >&2
echo "See: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" >&2
exit 1
fi
# Verify the credentials the signer will pick up are present and valid.
if ! caller_arn=$(aws sts get-caller-identity --query Arn --output text 2>/dev/null); then
echo "Error: AWS credentials are not configured properly or are invalid." >&2
echo "Please run 'aws configure' or check your environment variables/IAM role." >&2
exit 1
fi
echo "AWS credentials verified for: $caller_arn"
echo "Starting batch export from '$FILES_DIR' to bucket '$BUCKET' in region '$REGION'..."
# --- processing loop ---
failed=0
uploaded=0
# dotglob: a hidden file is an ordinary object to S3, and silently leaving
# .env.example behind is data loss. nullglob: an empty directory must not run
# the loop once with a literal '*'.
shopt -s dotglob nullglob
for file in "$FILES_DIR"/*; do
filename=$(basename "$file")
# Test for a symlink before -f, which follows them: a link inside FILES_DIR
# would otherwise upload bytes from outside it, under a name from inside it.
if [[ -L "$file" ]]; then
echo " Skipping '$filename': symbolic link."
continue
fi
# Skip directories and anything else that is not a regular file.
[[ -f "$file" ]] || continue
echo "Processing '$filename'..."
# A single PUT tops out at 5 GiB; larger objects need multipart upload.
# stat -f%z works on macOS/BSD, stat -c%s works on Linux.
file_size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file")
if ((file_size > 5368709120)); then
echo " Skipping '$filename': $((file_size / 1024 / 1024)) MiB exceeds the 5 GiB PUT limit." >&2
echo " Use 'aws s3 cp' instead, which switches to multipart automatically." >&2
failed=$((failed + 1))
continue
fi
content_type=$(file -b --mime-type "$file")
echo " Signing a PUT for '$filename' (Content-Type: $content_type)..."
if ! url=$(node "$PRESIGN" \
--bucket "$BUCKET" \
--key "$filename" \
--region "$REGION" \
--content-type "$content_type" \
--expires-in "$EXPIRE"); then
echo " Error: Failed to sign a URL for '$filename'." >&2
failed=$((failed + 1))
continue
fi
echo " Uploading '$filename'..."
# The Content-Type must match the signature. curl -f does not reject redirects;
# require a completed 2xx upload without replaying a signed body at another URL.
if status=$(curl -fsS --retry 3 --retry-delay 2 \
-T "$file" \
-H "Content-Type: $content_type" \
-o /dev/null -w '%{http_code}' \
"$url") && [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo " Successfully uploaded '$filename'."
uploaded=$((uploaded + 1))
else
echo " Error: Failed to upload '$filename'." >&2
failed=$((failed + 1))
fi
done
echo "Batch export completed: $uploaded uploaded, $failed failed."
# Surface partial failure to the caller: 'set -e' cannot do this for us, because
# every failure above was deliberately caught so the loop could continue.
if ((failed > 0)); then
exit 1
fi
Recuerda sustituir your-bucket-name, /path/to/your/files y your-region en el script. Haz que el script
sea ejecutable (chmod +x script_name.sh) antes de ejecutarlo.
Conviene señalar tres limitaciones con claridad. El script usa el nombre del archivo como clave del
objeto, por lo que archivos con el mismo nombre en distintos subdirectorios chocarían entre sí. Por
eso solo lee el nivel superior de FILES_DIR. curl --retry reenvía el archivo entero desde el byte cero,
porque el PUT prefirmado no admite reanudación: una subida de 4 GiB que se corta al 90 % empieza de
nuevo. Y una subida es una sobrescritura incondicional, así que volver a ejecutar el script sustituye
lo que ya haya en esas claves. Activa el versionado del bucket si necesitas recuperar los bytes
anteriores.
Aplica las prácticas recomendadas de seguridad
Cuando trabajas con recursos en la nube, la seguridad es fundamental:
- Prefiere los roles de IAM: cuando ejecutes scripts en instancias EC2 u otros servicios de AWS, usa roles de IAM para obtener credenciales temporales en lugar de claves de acceso de larga duración.
- Privilegio mínimo: concede únicamente el permiso
s3:PutObjectnecesario para esta tarea, limitado al bucket concreto. - Validez corta de las URL: mantén el valor de
--expires-inpara laspre-signed URLtan corto como resulte práctico para la duración de la subida. - Cifrado: activa el cifrado del lado del servidor (SSE-S3, SSE-KMS o SSE-C) en tu bucket de S3 para proteger los datos en reposo.
- Supervisión: calcular una firma no emite ningún evento de CloudTrail, así que nada deja
constancia de que se generó una URL. Lo que sí puedes observar es el canje: activa el registro de
acceso al servidor de S3 o los eventos de datos de CloudTrail para el bucket, que registran las
llamadas
PutObjectresultantes. Como una URL sigue siendo válida durante toda su vida útil, esos logs son también el lugar donde detectarías que alguien la reutiliza. - Endpoints de VPC: si tu script se ejecuta dentro de una VPC, usa endpoints de VPC para S3 y mantén el tráfico dentro de la red de AWS, evitando la internet pública.
Soluciona problemas comunes
- El firmador falla antes de cualquier subida: la firma se calcula localmente, pero resolver las
credenciales no siempre es local: la cadena de proveedores por defecto puede llamar a STS para una
sesión de SSO o de rol, o al servicio de metadatos de la instancia en EC2, y cualquiera de las dos
cosas puede fallar o agotar el tiempo de espera. La falta de credenciales se manifiesta como un
CredentialsProviderError. Confirma tu configuración conaws sts get-caller-identity. SignatureDoesNotMatchdurante la subida concurl: la solicitud difiere de lo que se firmó. Las causas habituales son unContent-Typeque no coincide con--content-type, una redirección-L, o una URL que se volvió a entrecomillar o que el shell expandió. Encierra siempre la URL entre comillas dobles: contiene&.Access Denieddurante la subida concurl: comprueba que la política de la identidad que firma permitas3:PutObjectenarn:aws:s3:::your-bucket-name/*, y revisa las políticas del bucket, la configuración de Block Public Access o una política de clave de KMS que pueda denegar la escritura.XAmzContentChecksumMismatchoBadDigest: la URL se firmó con el modo de suma de comprobación por defecto del SDK, que fija un CRC32 de un cuerpo vacío. EstablecerequestChecksumCalculationen'WHEN_REQUIRED'como se muestra arriba.- Tiempos de espera de red (errores
curl): aumenta el número de--retryo añade las opciones--connect-timeout/--max-timeacurlsi trabajas con redes lentas. Revisa la conectividad de red y los cortafuegos. - Archivo demasiado grande (error
EntityTooLargeu omisión por parte del script): para archivos de más de 5 GiB, la operación PUT única que usa este método decurlno funcionará. Recurre aAWS CLIy su comandoaws s3 cp, que gestiona automáticamente las subidas multiparte para archivos grandes. - Región incorrecta (
AuthorizationHeaderMalformed): el--regionque pasas al firmador queda incorporado tanto en el nombre de host como en el ámbito de las credenciales, así que tiene que coincidir con la región real del bucket.aws s3api get-bucket-location --bucket your-bucket-namete dirá cuál es. - URL caducada (
AccessDeniedoRequest has expired): lapre-signed URLsolo es válida durante el tiempo especificado por--expires-in. Vuelve a generar la URL si la subida tarda más de lo previsto o se intenta después de la expiración. - Tipo de contenido incorrecto: si los archivos no se comportan como esperas después de
descargarlos, vuelve a revisar el encabezado
Content-Typeque se estableció durante la subida concurl. Asegúrate de quefile --mime-typeestá indicando el tipo correcto.
¿Necesitas subir algo de más de 5 GiB? Deja que AWS CLI se encargue por ti de la complejidad de la
fragmentación multiparte:
# AWS CLI handles multipart uploads automatically for large files
aws s3 cp /path/to/your/large_file.zip s3://your-bucket-name/
Conclusión
Combinar un pequeño firmador del AWS SDK con curl para la transferencia te da un pipeline
flexible y open source para exportar archivos por lotes a Amazon S3, sin credenciales en el propio
comando de transferencia. El firmador es la parte que aws s3 presign no puede cubrir, ya que sus URL solo
autorizan un GET. Todo lo demás se queda en AWS CLI: la configuración de credenciales, la
verificación y la alternativa para archivos grandes.
Para flujos de trabajo más complejos que impliquen el procesamiento de archivos antes o después
del file export a S3, considera un servicio gestionado. Nuestro Robot
🤖 /s3/store, por ejemplo, encapsula un patrón similar y te permite exportar los
resultados de otros Steps de procesamiento directamente a S3 dentro de una sola Assembly.
¡Feliz programación!
