To work with S3 from Java people used to reach for AWS SDK v1 — bulky, with blocking I/O and an awkward API. The standard today is AWS SDK v2 (released in 2018): slightly different dependencies, an async HTTP client, immutable builders, proper support for modern Java. Let's walk through how to wire it into Spring without stepping on the usual rakes.
What to add to your dependencies
dependencies {
implementation(platform("software.amazon.awssdk:bom:2.x.x"))
implementation("software.amazon.awssdk:s3")
implementation("software.amazon.awssdk:s3-transfer-manager") // for large files
implementation("software.amazon.awssdk:netty-nio-client") // async HTTP
}
The bom aligns the versions of all AWS modules automatically — you don't have to pin a version on each dependency separately.
An alternative is Spring Cloud AWS: auto-configuration, properties from application.yml, integration with Spring. Convenient for standard AWS. If you need MinIO or another S3-compatible server, it's simpler to configure the SDK by hand, as shown below.
How to configure S3Client in Spring
The main object for working with S3 is S3Client. We register it as a Spring bean:
@Configuration
public class S3Config {
@Bean
public S3Client s3Client(
@Value("${aws.s3.region}") String region,
@Value("${aws.s3.endpoint:#{null}}") String endpoint,
@Value("${aws.s3.access-key}") String accessKey,
@Value("${aws.s3.secret-key}") String secretKey) {
var builder = S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)));
if (endpoint != null) {
builder.endpointOverride(URI.create(endpoint))
.forcePathStyle(true);
}
return builder.build();
}
@Bean
public S3Presigner s3Presigner(
@Value("${aws.s3.region}") String region,
@Value("${aws.s3.endpoint:#{null}}") String endpoint,
@Value("${aws.s3.access-key}") String accessKey,
@Value("${aws.s3.secret-key}") String secretKey) {
var builder = S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)));
if (endpoint != null) {
builder.endpointOverride(URI.create(endpoint));
}
return builder.build();
}
}
Settings in application.properties:
aws.s3.region=eu-west-1
aws.s3.access-key=${S3_ACCESS_KEY}
aws.s3.secret-key=${S3_SECRET_KEY}
# For AWS S3 — no endpoint needed, leave it empty.
# For MinIO:
# aws.s3.endpoint=http://minio:9000
# For Yandex Object Storage:
# aws.s3.endpoint=https://storage.yandexcloud.net
# aws.s3.region=ru-central1
# For Cloudflare R2:
# aws.s3.endpoint=https://<account-id>.r2.cloudflarestorage.com
# aws.s3.region=auto
forcePathStyle(true) is required for MinIO: it expects URLs of the form endpoint/bucket/key, whereas AWS by default uses bucket.endpoint/key. For real AWS S3 you can leave the default.
In production on EC2 or EKS the access-key/secret-key pair is usually not set — the SDK finds credentials itself through the IAM Instance Profile (just don't pass a credentialsProvider, and DefaultCredentialsProvider kicks in).
Uploading a file
A plain PUT
For small files putObject is enough:
@Service
@RequiredArgsConstructor
public class AvatarService {
private final S3Client s3;
private final String bucket = "user-avatars";
public void upload(UUID userId, MultipartFile file) throws IOException {
String key = "users/%s/avatar.jpg".formatted(userId);
s3.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize())
);
}
}
Specifying contentLength is mandatory — otherwise the SDK buffers the whole file in memory to compute its size. RequestBody can accept an InputStream, File, Path, byte[] or String.
Large files via TransferManager
For files of several megabytes and up, instead of S3Client it's more convenient to use S3TransferManager — it splits the file into parts itself and uploads them in parallel (multipart upload):
@Bean
public S3AsyncClient s3AsyncClient(/* the same parameters */) {
// same as S3Client, but via S3AsyncClient.builder()
}
@Bean
public S3TransferManager transferManager(S3AsyncClient s3Async) {
return S3TransferManager.builder().s3Client(s3Async).build();
}
@Service
@RequiredArgsConstructor
public class VideoService {
private final S3TransferManager tm;
public void upload(Path videoFile, UUID userId) {
String key = "users/%s/video-%s.mp4".formatted(userId, UUID.randomUUID());
FileUpload upload = tm.uploadFile(b -> b
.source(videoFile)
.putObjectRequest(p -> p.bucket("videos").key(key)));
upload.completionFuture().join();
}
}
TransferManager decides on its own when to switch to multipart (by default — from 8 MB) and retries on network errors.
Downloading a file
// A small file entirely into memory
public byte[] download(String key) {
return s3.getObjectAsBytes(
GetObjectRequest.builder().bucket(bucket).key(key).build()
).asByteArray();
}
// Save straight into a file on disk
public void downloadToFile(String key, Path target) {
s3.getObject(
GetObjectRequest.builder().bucket(bucket).key(key).build(),
target);
}
// Streaming read — important to close the InputStream
public void processStream(String key) {
try (InputStream stream = s3.getObject(
GetObjectRequest.builder().bucket(bucket).key(key).build())) {
// read the stream in chunks
}
}
Don't forget to close the InputStream after a streaming read. The SDK doesn't do it automatically, and an unclosed connection stays "busy" in the pool — after a few such cases new requests start hanging.
For files larger than 10 MB you shouldn't use getObjectAsBytes — you'd load the whole content into memory. A streaming read or downloading straight into a file is better.
Presigned URLs
Sometimes you want the client to upload a file directly to S3 — without going through your backend. For this the server generates a presigned URL: a temporary signed link that the browser or mobile app can use to PUT straight into S3.
@Service
@RequiredArgsConstructor
public class UploadUrlService {
private final S3Presigner presigner;
public String generateUploadUrl(UUID userId) {
String key = "users/%s/avatar.jpg".formatted(userId);
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket("user-avatars")
.key(key)
.contentType("image/jpeg")
.contentLength(5 * 1024 * 1024L)
.build();
PresignedPutObjectRequest presigned = presigner.presignPutObject(p -> p
.signatureDuration(Duration.ofMinutes(10))
.putObjectRequest(objectRequest));
return presigned.url().toString();
}
}
The client receives the URL and does a PUT with Content-Type: image/jpeg directly to S3. If it adds other headers or changes the parameters, the signature won't match and S3 will return an error.
presignGetObject works the same way — for downloading a private file via a temporary link.
How to test without a real S3
In tests it's handy to use MinIO — an S3-compatible server that starts in Docker and behaves like a real S3. Testcontainers launches it right from the test:
@SpringBootTest
@Testcontainers
class AvatarServiceTest {
@Container
static MinIOContainer minio = new MinIOContainer("minio/minio:latest")
.withUserName("test")
.withPassword("testtest");
@DynamicPropertySource
static void s3Props(DynamicPropertyRegistry registry) {
registry.add("aws.s3.endpoint", () -> minio.getS3URL());
registry.add("aws.s3.access-key", minio::getUserName);
registry.add("aws.s3.secret-key", minio::getPassword);
registry.add("aws.s3.region", () -> "us-east-1");
}
@Autowired private AvatarService avatarService;
@Autowired private S3Client s3;
@BeforeEach
void createBucket() {
s3.createBucket(b -> b.bucket("user-avatars"));
}
@Test
void uploads_avatar() throws Exception {
UUID userId = UUID.randomUUID();
avatarService.upload(userId, new MockMultipartFile(
"file", "avatar.jpg", "image/jpeg", "binary".getBytes()));
var meta = s3.headObject(b -> b
.bucket("user-avatars")
.key("users/" + userId + "/avatar.jpg"));
assertThat(meta.contentLength()).isEqualTo(6);
}
}
The test works without an AWS account, in CI, in isolation. MinIOContainer is available in org.testcontainers:minio.
For local development MinIO starts via Docker Compose:
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio12345
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web UI
volumes:
- minio-data:/data
volumes:
minio-data:
After starting, aws.s3.endpoint=http://localhost:9000 — and your service works with local MinIO.
The problem: "DB + S3" without guarantees
A typical situation: a user uploads a document, you need to store the file in S3 and create a record in the database. The problem is that S3 doesn't support transactions. If a failure happens between the PUT to S3 and the INSERT into the DB, you end up with either a file without a DB record, or a record without a file.
@Transactional won't help here — S3 doesn't take part in a Spring transaction.
Recommended pattern: upload via a presigned URL
The client uploads the file directly to S3, the backend only coordinates:
1. The client sends metadata: { name, size, contentType }
2. The backend creates Document(s3Key, status="PENDING") in the DB,
generates a presigned URL and hands it to the client.
3. The client does a PUT to the presigned URL directly to S3.
4. The client calls POST /api/docs/{id}/confirm.
5. The backend verifies the file via HeadObject, updates status="UPLOADED".
Each step is atomic. Records with status PENDING and no file in S3 are cleaned up by a background job every N minutes.
Pattern: Outbox for deletion
When you need to delete a file from S3 along with a record from the DB, an outbox is used: in a single transaction you delete the record and put a job into the outbox_events table, and a background job performs the S3 deletion:
@Transactional
public void deleteDocument(UUID id) {
var doc = docRepo.findById(id).orElseThrow();
docRepo.delete(doc);
outboxRepo.save(new OutboxEvent("s3.delete", doc.getS3Key()));
}
@Scheduled(fixedDelay = 5000)
public void processS3Outbox() {
var events = outboxRepo.fetchUnpublished("s3.delete", 100);
for (var event : events) {
s3.deleteObject(b -> b.bucket("docs").key(event.payload()));
outboxRepo.markPublished(event.id());
}
}
If the S3 deletion fails, it will be retried on the next run of the job until it succeeds.
In short
- AWS SDK v2 is the standard for S3 in Java: non-blocking HTTP, immutable builders, virtual threads.
S3Client— for ordinary operations;S3TransferManager— for large files with automatic multipart.forcePathStyle(true)is mandatory for MinIO and other S3-compatible servers.- In production, credentials come from the IAM Instance Profile, not from env variables.
- Presigned URLs let the client upload files directly to S3, bypassing the backend.
- The
InputStreamaftergetObjectmust be closed manually — otherwise you leak connections. - For tests —
MinIOContainerfrom Testcontainers: a full-fledged S3 without an AWS account. - S3 is non-transactional: atomicity of "DB + S3" is achieved via a presigned URL or an outbox.
What to read next
- S3 fundamentals — how the storage works: bucket, object, key, storage classes, versioning.
- S3 operations — backups, replication, cost, monitoring.