You’ve got local disk file saving working, but now you want to store files in S3 in production. This article covers everything from choosing between AWS SDK v2 and spring-cloud-aws to generating presigned URLs. For the basics of handling MultipartFile, refer to this article.

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

AWS SDK v2 is the official AWS library and is independent of Spring. It has a lightweight dependency footprint and gives you fine-grained control.

spring-cloud-aws 3.x uses AutoConfiguration to automatically create an S3Client for you. It requires less configuration, but you need to pay attention to version compatibility with Spring Boot itself.

For straightforward S3 operations, AWS SDK v2 is the safer choice. This article focuses primarily on that approach, with spring-cloud-aws differences covered as supplementary notes.

Adding Dependencies

Use a BOM to centralize version management. The versions below were current at the time of writing — check Maven Central for the latest.

<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'

If you use spring-cloud-aws, add its dedicated BOM and starter. Always include the BOM — omitting it frequently causes 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 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

You can also use environment variables instead.

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 encounter an SdkClientException, the cause is almost always that credentials cannot be found or the region is misconfigured. In production, never hardcode access keys in your code — use IAM roles instead. For switching between environments, 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 the resource when the application shuts down. Some versions of Spring Boot auto-detect AutoCloseable, but being explicit is safer.

Externalize the bucket name and region to 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, the s3Presigner() Bean in your S3Config class cannot be omitted. Configure the region via spring.cloud.aws.region.static.

spring:
  cloud:
    aws:
      region:
        static: ap-northeast-1
      credentials:
        # For local development only. Use IAM roles in production — do not set access-key/secret-key there.
        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 consolidate your 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 an explicit contentLength, fromInputStream() will throw an error
    s3Client.putObject(request,
            RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
    return key;
}

The key uses the userId/uuid_filename format to guarantee uniqueness. After uploading, save the key to 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 with try-with-resources. Failing to close it will leak HTTP connections. When contentType is null, APPLICATION_OCTET_STREAM is used as the default.

NoSuchKeyException is most often 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 for files larger than 10–20 MB. Reading all data into a byte[] risks an OOM error. Here is a sketch of the switch:

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 a Presigned URL

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. Setting it too long increases the risk if the URL is leaked.

IAM Role Configuration for Production

In production, use IAM roles instead of 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 automatically detects IAM roles, so no code changes are required. Below is a sample minimal-permission policy (this is a sample of one Statement element — 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 are working through an ECS deployment in combination with the Docker containerization article, don’t forget to configure the task role as well.

Summary

  • Define S3Client and S3Presigner as Beans. S3Presigner must be defined manually even when using spring-cloud-aws.
  • For uploads, use PutObjectRequest + RequestBody.fromInputStream(). contentLength is required.
  • For downloads, always close the stream with try-with-resources. Don’t forget the null guard on contentType.
  • For files larger than 10–20 MB, use StreamingResponseBody to return responses in a memory-efficient manner.
  • For presigned URLs, use s3Presigner.presignGetObject() to issue a URL with an expiration.
  • Use ~/.aws/credentials locally and IAM roles in production.

If you want to offload upload processing asynchronously, check out the async processing article as well.