← Back to the section

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.

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 InputStream after getObject must be closed manually — otherwise you leak connections.
  • For tests — MinIOContainer from 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.
  • S3 fundamentals — how the storage works: bucket, object, key, storage classes, versioning.
  • S3 operations — backups, replication, cost, monitoring.