Local disk file storage works, but in production you want to save to S3. For those wondering whether to use AWS SDK v2 or spring-cloud-aws, this article explains everything from selection criteria to presigned URL generation. For basic MultipartFile handling, see this article.

AWS SDK v2 vs spring-cloud-aws 3.x

AWS SDK v2 is the official AWS library and has no Spring dependency. Its dependencies are lightweight, and it offers fine-grained control.

spring-cloud-aws 3.x automatically generates an S3Client via AutoConfiguration. While it requires less configuration, you need to be careful about version management with Spring Boot itself.

For simple S3 operations, AWS SDK v2 is the safer choice. This article focuses on it, with notes about spring-cloud-aws differences as supplementary information.

Adding Dependencies

Let’s centralize version management with BOM. The versions below are current at the time of writing. Check Maven Central for the latest versions.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>software.amazon.awssdk</groupId>
      <artifactId>bom</artifactId>
      <version>2.25.60</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
  </dependency>
</dependencies>

For Gradle:

implementation platform('software.amazon.awssdk:bom:2.25.60')
implementation 'software.amazon.awssdk:s3'

When using spring-cloud-aws, add its dedicated BOM and starter. Always use the BOM to avoid version conflicts.

implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1')
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'

Authentication Setup for Local Development

The easiest way is to create ~/.aws/credentials using aws configure.

[default]
aws_access_key_id = YOUR_ACCESS_KEY
aws_secret_access_key = YOUR_SECRET_KEY
region = ap-northeast-1

Environment variables can also be used as an alternative.

export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY
export AWS_DEFAULT_REGION=ap-northeast-1

If you get a SdkClientException, it’s usually because credentials can’t be found or the region setting is wrong. In production, don’t embed access keys in your code—use IAM roles instead. For environment switching, refer to the Spring Profiles article.

Defining S3Client and S3Presigner as Beans

@Configuration
public class S3Config {

    @Value("${aws.s3.region}")
    private String region;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean(destroyMethod = "close")
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

S3Presigner implements AutoCloseable. Specifying @Bean(destroyMethod = "close") ensures Spring reliably releases resources when the app shuts down. Depending on the Spring Boot version, AutoCloseable may be auto-detected, but explicit declaration is safer.

Externalize the bucket name and region in application.yml.

aws:
  s3:
    region: ap-northeast-1
    bucket-name: my-app-bucket

With spring-cloud-aws, S3Client is auto-generated by AutoConfiguration. However, since S3Presigner is not covered by AutoConfiguration, you cannot omit the s3Presigner() Bean in the S3Config class even when using spring-cloud-aws. Configure the region with spring.cloud.aws.region.static.

spring:
  cloud:
    aws:
      region:
        static: ap-northeast-1
      credentials:
        # For local development. In production, use IAM roles and don't set access-key/secret-key
        access-key: YOUR_ACCESS_KEY
        secret-key: YOUR_SECRET_KEY

Implementing Upload, Download, and Presigned URL

Inject S3Client and S3Presigner into the same Service to consolidate S3 operations.

Upload

public String upload(MultipartFile file, String userId) throws IOException {
    String originalFilename = file.getOriginalFilename() != null
            ? file.getOriginalFilename() : "file";
    String key = userId + "/" + UUID.randomUUID() + "_" + originalFilename;

    PutObjectRequest request = PutObjectRequest.builder()
            .bucket(bucketName)
            .key(key)
            .contentType(file.getContentType())
            .contentLength(file.getSize())
            .build();

    // Without explicit contentLength, fromInputStream() will throw an error
    s3Client.putObject(request,
            RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
    return key;
}

The key format userId/uuid_filename ensures uniqueness. After uploading, store the key in your database.

Download

public ResponseEntity<byte[]> download(String key) {
    try {
        GetObjectRequest request = GetObjectRequest.builder()
                .bucket(bucketName).key(key).build();

        try (ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(request)) {
            byte[] content = s3Object.readAllBytes();
            HttpHeaders headers = new HttpHeaders();
            String ct = s3Object.response().contentType();
            headers.setContentType(ct != null
                    ? MediaType.parseMediaType(ct)
                    : MediaType.APPLICATION_OCTET_STREAM);
            return ResponseEntity.ok().headers(headers).body(content);
        }
    } catch (NoSuchKeyException e) {
        return ResponseEntity.notFound().build();
    } catch (IOException e) {
        throw new RuntimeException("Download failed", e);
    }
}

Always close ResponseInputStream using try-with-resources. Failing to close it will leak HTTP connections. When contentType is null, we default to APPLICATION_OCTET_STREAM.

NoSuchKeyException is often caused by typos in the key name or mismatches between the keys used during upload and retrieval.

As a rule of thumb, consider migrating to StreamingResponseBody for files larger than 10–20 MB. Reading all data into a byte[] carries OOM risk. Here’s what the migration looks like:

public ResponseEntity<StreamingResponseBody> downloadAsStream(String key) {
    GetObjectRequest request = GetObjectRequest.builder()
            .bucket(bucketName).key(key).build();
    ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(request);
    StreamingResponseBody body = out -> {
        try (s3Object) { s3Object.transferTo(out); }
    };
    String ct = s3Object.response().contentType();
    MediaType mediaType = ct != null
            ? MediaType.parseMediaType(ct) : MediaType.APPLICATION_OCTET_STREAM;
    return ResponseEntity.ok().contentType(mediaType).body(body);
}

Generating Presigned URLs

public String generatePresignedUrl(String key, Duration expiration) {
    GetObjectRequest getObjectRequest = GetObjectRequest.builder()
            .bucket(bucketName).key(key).build();

    PresignedGetObjectRequest presigned = s3Presigner.presignGetObject(r ->
            r.signatureDuration(expiration)
             .getObjectRequest(getObjectRequest));
    return presigned.url().toString();
}

An expiration of 5–15 minutes is appropriate. Longer expirations increase the risk if the URL is leaked.

Production IAM Role Configuration

Common Errors and Solutions

Here are common errors encountered with S3 integration.

AccessDenied (403)

In most cases, the IAM policy’s Resource doesn’t point to the objects within the bucket (arn:aws:s3:::my-app-bucket/*). If you use ListBucket, you also need separate permissions on the bucket itself (arn:aws:s3:::my-app-bucket). Also check whether bucket policies explicitly deny access.

MaxUploadSizeExceededException

You’re hitting Spring Boot’s upload size limit. Increase it in application.yml.

spring:
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

Don’t forget to also align the limits on reverse proxies (such as Nginx’s client_max_body_size).

CORS Error When PUTing Directly to a Presigned URL from a Browser

You need to configure CORS on the S3 bucket. Add a configuration like the following via the management console.

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

S3Exception: The bucket is in a different region

The S3Client region and the bucket region don’t match. Set aws.s3.region in application.yml to the actual bucket region. You can confirm with aws s3api get-bucket-location --bucket my-app-bucket.

In production, use IAM roles instead of access keys.

  • For EC2, attach the policy to the instance profile
  • For ECS, attach the policy to the task role
  • For Lambda, attach the policy to the execution role

AWS SDK v2’s DefaultCredentialsProvider automatically detects IAM roles, so no code changes are required. Below is a sample of a least-privilege configuration (this is just a single Statement element sample; refer to the AWS documentation for the complete policy document format).

{
  "Effect": "Allow",
  "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
  "Resource": "arn:aws:s3:::my-app-bucket/*"
}

If you’re combining this with the Docker containerization article and deploying to ECS, don’t forget to configure the task role.

Summary

  • Define S3Client and S3Presigner as Beans. S3Presigner requires manual definition even when using spring-cloud-aws
  • For uploads, use PutObjectRequest + RequestBody.fromInputStream(). contentLength is required
  • For downloads, ensure streams are closed with try-with-resources. Don’t forget null guards on contentType
  • For files larger than 10–20 MB, use StreamingResponseBody to return responses with efficient memory usage
  • Generate presigned URLs with expirations via s3Presigner.presignGetObject()
  • Use ~/.aws/credentials locally, IAM roles in production

When exposing an upload API, validating file types and sizes following the custom validation annotation article makes the implementation robust. If you want to format S3-related exceptions for production responses, using a unified ProblemDetail-based response from the GlobalExceptionHandler article makes operations easier. When deploying to ECS/EKS, also check the IAM Roles for Service Accounts configuration in the Kubernetes deployment article.

If you want to decouple upload processing asynchronously, see the asynchronous processing article for reference.

FAQ

Q. Which should I choose for production: AWS SDK v2 or spring-cloud-aws? A. If you only use S3, AWS SDK v2 is recommended because its dependencies are lighter. If you use multiple AWS services like SQS / SNS / Parameter Store across the board, spring-cloud-aws’s AutoConfiguration provides significant configuration reduction benefits.

Q. How do I safely upload large files? A. AWS SDK v2’s S3TransferManager (software.amazon.awssdk:s3-transfer-manager) automatically handles multipart uploads. It’s especially useful for files over 100MB or in unstable network environments.

Q. What expiration should I set for presigned URLs? A. For download use cases, 5–15 minutes is reasonable; for upload use cases (PUT directly from a browser), within 15 minutes is realistic. Longer expirations increase the risk if the URL leaks, so keep it to the minimum needed.

Q. I don’t want to use real S3 for local testing A. LocalStack or MinIO can run an S3-compatible API locally. By switching the endpoint with S3Client.builder().endpointOverride(URI.create("http://localhost:4566")), you can test without changing your production code.