Here is the English translation of the article body.

Saving files to local disk works, but in production you want them in S3. If you’re unsure whether to use the AWS SDK v2 or spring-cloud-aws, this article covers everything from the selection criteria to generating presigned URLs. For the basics of handling MultipartFile, see this article.

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

AWS SDK v2 is the official AWS library and has no dependency on Spring. It’s lightweight and gives you fine-grained control.

spring-cloud-aws 3.x auto-generates an S3Client for you via AutoConfiguration. It requires less configuration, but you need to be careful about version compatibility with Spring Boot itself.

If all you need is simple S3 operations, AWS SDK v2 is the safe choice. On the other hand, if you’re using multiple AWS services such as SQS, SNS, or Parameter Store, the configuration savings from spring-cloud-aws become significant. This article focuses on AWS SDK v2 and covers the differences for spring-cloud-aws as supplementary notes.

Adding Dependencies

Use a BOM to manage versions centrally. The versions below are current as of the time of writing. Check Maven Central for the latest releases.

<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, use the following.

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

If you’re using spring-cloud-aws, add its dedicated BOM and starter. Omitting the BOM makes version conflicts likely, so always include it.

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

Credential Setup for Local Development

The easiest approach is to run aws configure to create ~/.aws/credentials.

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

Environment variables work 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 see an SdkClientException, the cause is almost always missing credentials or an incorrect region setting. In production, never embed access keys in your code. Use IAM roles instead. For switching configuration per environment, see the Spring Profiles article.

If you want to develop without touching real S3, you can run an S3-compatible API locally with LocalStack or MinIO. Point the client at it with S3Client.builder().endpointOverride(URI.create("http://localhost:4566")) and you can test without changing your production code.

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 the resource when the application shuts down. Some Spring Boot versions detect AutoCloseable automatically, but declaring it explicitly is safer.

Externalize the bucket name and region into application.yml.

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

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

spring:
  cloud:
    aws:
      region:
        static: ap-northeast-1
      credentials:
        # For local development only. In production, use an IAM role and do not set access-key/secret-key
        access-key: YOUR_ACCESS_KEY
        secret-key: YOUR_SECRET_KEY

Implementing Upload, Download, and Presigned URLs

Inject both S3Client and S3Presigner into the same Service to keep your S3 operations together.

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();

    // contentLength を明示しないと fromInputStream() でエラーになる
    s3Client.putObject(request,
            RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
    return key;
}

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

If you’re dealing with files over 100MB or unreliable networks, consider switching to S3TransferManager (software.amazon.awssdk:s3-transfer-manager). It handles multipart uploads automatically.

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("ダウンロードに失敗しました", e);
    }
}

Always close ResponseInputStream with try-with-resources. If you don’t, HTTP connections will leak. When contentType is null, the code falls back to APPLICATION_OCTET_STREAM.

NoSuchKeyException is usually caused by a typo in the key name or a mismatch between the key used at upload time and the key used at retrieval time.

As a rule of thumb, consider migrating to StreamingResponseBody when handling files larger than 10 to 20 MB. Reading all the data into a byte[] carries an OOM risk. Here’s what the switch 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();
}

For download use cases, an expiration of 5 to 15 minutes is realistic. For uploads where the browser PUTs directly to S3, keep it within 15 minutes as well. The longer the expiration, the greater the risk if the URL leaks, so keep it to the minimum necessary.

Common Errors and Fixes

Here’s a rundown of errors you’re likely to encounter when integrating with S3.

AccessDenied (403)

In most cases, the Resource in the IAM policy doesn’t point to the objects within the bucket (arn:aws:s3:::my-app-bucket/*). If you use ListBucket, you also need separate permission on the bucket itself (arn:aws:s3:::my-app-bucket). Also check that the bucket policy doesn’t contain an explicit Deny.

MaxUploadSizeExceededException

You’ve hit the upload limit on the Spring Boot side. Raise it in application.yml.

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

Don’t forget to align the limit on the reverse proxy as well (such as client_max_body_size in Nginx).

CORS Error When the Browser PUTs Directly to a Presigned URL

The S3 bucket needs a CORS configuration. Add something like the following from 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 doesn’t match the bucket’s region. Set aws.s3.region in application.yml to the bucket’s actual region. You can check it with aws s3api get-bucket-location --bucket my-app-bucket.

IAM Role Configuration for Production

In production, use IAM roles rather than access keys.

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

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

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

If you’re working toward an ECS deployment alongside the Docker containerization article, don’t forget to configure the task role.

Summary

  • Define S3Client and S3Presigner as Beans. S3Presigner must be defined manually even when using spring-cloud-aws
  • Upload with PutObjectRequest + RequestBody.fromInputStream(). contentLength is required
  • For downloads, reliably close the stream with try-with-resources. Don’t forget the null guard on contentType
  • For files over 10 to 20 MB, return the response memory-efficiently with StreamingResponseBody
  • Issue time-limited presigned URLs with s3Presigner.presignGetObject()
  • Use ~/.aws/credentials locally and IAM roles in production

When exposing an upload API, implementing file type and size validation as described in the custom validation annotation article makes it more robust. If you want to shape S3-related exceptions into production-ready responses, the GlobalExceptionHandler article shows how to unify them with ProblemDetail, which makes operations easier. If you’re deploying to ECS or EKS, also review the IAM Roles for Service Accounts setup in the Kubernetes deployment article.

If you want to decouple upload processing asynchronously, the async processing article may also be helpful.