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
S3ClientandS3Presigneras Beans.S3Presignermust be defined manually even when using spring-cloud-aws. - For uploads, use
PutObjectRequest+RequestBody.fromInputStream().contentLengthis 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
StreamingResponseBodyto return responses in a memory-efficient manner. - For presigned URLs, use
s3Presigner.presignGetObject()to issue a URL with an expiration. - Use
~/.aws/credentialslocally and IAM roles in production.
If you want to offload upload processing asynchronously, check out the async processing article as well.