Fast, Cheap, and Scalable File Uploads with Spring Boot and Cloudflare R2

In this post, we’ll explore how to build a file upload service using Spring Boot and Cloudflare R2.


Prerequisites

This is the list of all the prerequisites:

  • Spring Boot 3+
  • Maven 3.9.x
  • Java 21
  • A Cloudflare account with R2 enabled
  • Postman

Overview

What is Cloudflare R2 ?

Cloudflare R2 is a distributed, S3-compatible object storage service that allows developers to store large amounts of unstructured data — such as images, videos, and application asset.

Why Cloudflare R2?

Traditional object storage solutions charge for both storage and data transfer (egress). If your app serves user-uploaded files (images, videos, documents), those costs can explode.

Cloudflare R2 changes the game:

  • Fast uploads with presigned URLs.
  • Cheap storage + no egress charges.
  • Scalable architecture ready for millions of files.

Setting Up Cloudflare R2

1. Create a Bucket

  • Log into the Cloudflare Dashboard.
  • Navigate to R2 Object Storage.
  • Click Create bucket.
  • Enter a bucket name (e.g., spring-boot-bucket).
  • Choose a region.
  • Click Create.

2. Generate API Credentials

  • In the R2 section, go to Manage R2 API Tokens.
  • Click Create Account API Token.
  • Select Object Read & Write permissions.
  • Scope the token to your specific bucket.

In my case, I set the TTL to one week, but you can configure it to never expire.

  • Copy the following credentials:
  • Access Key ID
  • Secret Access Key (shown only once)

⚠️ Store your Secret Access Key securely. You will not be able to view it again.

Spring Boot API

Let’s create a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Web, Validation and Lombok.

Configure Cloudflare R2 in Spring Boot

Add Dependencies

We’ll use the AWS SDK (since R2 is S3-compatible) in thepom.xml::

 <properties>
<aws.sdk.version>2.42.26</aws.sdk.version>
</properties>

<dependencies>
<!-- AWS SDK v2 — S3 client (S3-compatible, works with Cloudflare R2) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>${aws.sdk.version}</version>
</dependency>
</dependencies>

Configure R2 Credentials

Add these to the application.yml:

cloudflare:
r2:
# Format: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
access-key: <YOUR_ACCESS_KEY_ID>
secret-key: <YOUR_SECRET_ACCESS_KEY>
bucket: <YOUR_BUCKET_NAME>


spring:
application:
name: springboot-cloudflare-r2-file-upload
servlet:
multipart:
# Maximum size of a single uploaded file (adjust to your needs)
max-file-size: 100MB
# Maximum size of the entire multipart/form-data request
max-request-size: 200MB

Tip: Never commit secrets to version control. Use environment variables or Spring’s @ConfigurationProperties with a secrets manager in production.

Configure R2 Client

@Slf4j
@RequiredArgsConstructor
@Configuration
public class CloudflareR2Config {

private final CloudflareProperties cloudflareProperties;

@Bean
public S3Client s3Client() {
log.debug("Initialising Cloudflare R2 S3Client — endpoint: {}, bucket: {}",
cloudflareProperties.getEndpoint(),
cloudflareProperties.getBucket());

AwsBasicCredentials credentials = AwsBasicCredentials.create(
cloudflareProperties.getAccessKey(),
cloudflareProperties.getSecretKey()
);

return S3Client.builder()
.endpointOverride(URI.create(cloudflareProperties.getEndpoint()))
// R2 does not use real AWS regions; "auto" is accepted by the SDK
.region(Region.of("auto"))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
// Required for R2: virtual-hosted style is not supported
.forcePathStyle(true)
.build();
}

}

Build the Storage Service

@Slf4j
@RequiredArgsConstructor
@Service
public class FileStorageServiceImpl implements FileStorageService {

private final S3Client s3Client;
private final S3Presigner s3Presigner;
private final CloudflareProperties cloudflareProperties;

/**
* {@inheritDoc}
*
* <p>Runs asynchronously so the HTTP response is returned to the client
* immediately. The object key is the sanitised original file name.
*
* <p>For files larger than ~10 MB you should switch to multipart upload
* (see class-level Javadoc on large-file handling).
*/
@Async
@Override
public void uploadFile(MultipartFile multipartFile) {
validateFile(multipartFile);

String fileName = sanitiseFileName(
Objects.requireNonNull(multipartFile.getOriginalFilename()));
String bucket = cloudflareProperties.getBucket();

log.info("Uploading file '{}' ({} bytes) to bucket '{}'",
fileName, multipartFile.getSize(), bucket);

try {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType(multipartFile.getContentType())
.contentLength(multipartFile.getSize())
.build();

s3Client.putObject(request,
RequestBody.fromInputStream(
multipartFile.getInputStream(),
multipartFile.getSize()));

log.info("File '{}' uploaded successfully to bucket '{}'", fileName, bucket);

} catch (IOException e) {
throw new FileStorageException(
"Failed to read file '%s' from request".formatted(fileName), e);
} catch (S3Exception e) {
throw new FileStorageException(
"R2 rejected the upload of '%s': %s".formatted(fileName, e.awsErrorDetails().errorMessage()), e);
} catch (SdkException e) {
throw new FileStorageException(
"Network/SDK error while uploading '%s'".formatted(fileName), e);
}
}


/**
* {@inheritDoc}
*
* <p>Returns an {@link InputStreamResource} backed by the R2 response stream.
* The stream is consumed by the framework when writing the HTTP response body —
* do not buffer the entire file into memory.
*/
@Override
public InputStreamResource downloadFile(String fileName) {
validateFileName(fileName);

String bucket = cloudflareProperties.getBucket();
log.info("Downloading file '{}' from bucket '{}'", fileName, bucket);

try {
GetObjectRequest request = GetObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.build();

// ResponseInputStream implements InputStream — wrap directly; no heap copy.
return new InputStreamResource(s3Client.getObject(request));

} catch (NoSuchKeyException e) {
throw new FileNotFoundException(
"File '%s' not found in bucket '%s'".formatted(fileName, bucket));
} catch (S3Exception e) {
throw new FileStorageException(
"R2 error while downloading '%s': %s".formatted(fileName, e.awsErrorDetails().errorMessage()), e);
} catch (SdkException e) {
throw new FileStorageException(
"Network/SDK error while downloading '%s'".formatted(fileName), e);
}
}

/**
* {@inheritDoc}
*
* <p>Runs asynchronously. R2 (like S3) does not throw an error when the
* object key does not exist, so no pre-existence check is needed.
*/
@Async
@Override
public void deleteFile(String fileName) {
validateFileName(fileName);

String bucket = cloudflareProperties.getBucket();
log.info("Deleting file '{}' from bucket '{}'", fileName, bucket);

try {
DeleteObjectRequest request = DeleteObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.build();

s3Client.deleteObject(request);
log.info("File '{}' deleted successfully from bucket '{}'", fileName, bucket);

} catch (S3Exception e) {
throw new FileStorageException(
"R2 error while deleting '%s': %s".formatted(fileName, e.awsErrorDetails().errorMessage()), e);
} catch (SdkException e) {
throw new FileStorageException(
"Network/SDK error while deleting '%s'".formatted(fileName), e);
}
}

// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------

/** Rejects null / empty files before attempting any SDK call. */
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new FileStorageException("Cannot upload an empty or null file");
}
if (!StringUtils.hasText(file.getOriginalFilename())) {
throw new FileStorageException("File must have a non-blank original filename");
}
}

/** Rejects blank file-name strings that would produce an invalid object key. */
private void validateFileName(String fileName) {
if (!StringUtils.hasText(fileName)) {
throw new FileStorageException("fileName must not be blank");
}
}

/**
* Removes path traversal sequences from the file name so an uploaded file
* like {@code ../../etc/passwd} cannot overwrite unexpected objects.
*/
private String sanitiseFileName(String originalFilename) {
// StringUtils.cleanPath normalises separators and removes "../"
return StringUtils.cleanPath(originalFilename);
}
}

This class provides file storage functionality using an S3-compatible service, specifically Cloudflare R2 (which follows the AWS S3 API). It supports file upload, download, and deletion operations, with built-in validation, structured logging, and robust error handling to ensure reliability and maintainability.

Wire Up the Controller

Let’s see all the methods of our controller class.

@Slf4j
@RestController
@RequestMapping("/file")
@Validated
public class FileController {

private final FileStorageService fileStorageService;

public FileController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}

@PostMapping("/upload")
public ResponseEntity<BaseResponse> uploadFile(
@RequestParam("file")
@FileRequired
@FileMaxSize
@ValidFileType MultipartFile file) {

log.info("POST /file/upload — file='{}', size={} bytes",
file.getOriginalFilename(), file.getSize());

fileStorageService.uploadFile(file);

return ResponseEntity
.status(HttpStatus.CREATED)
.body(new BaseResponse(
new FileResponse(file.getOriginalFilename(), file.getContentType(), file.getSize()),
"File uploaded successfully."));
}

@PostMapping("/upload-files")
public List<ResponseEntity<BaseResponse>> uploadMultipleFiles(
@RequestParam("files") MultipartFile[] files) {

log.info("POST /file/upload-files — {} file(s)", files.length);
return Arrays.stream(files)
.map(this::uploadFile)
.toList();
}


@GetMapping("/download")
public ResponseEntity<InputStreamResource> downloadFile(
@RequestParam("fileName") String fileName) {

log.info("GET /file/download — fileName='{}'", fileName);
InputStreamResource resource = fileStorageService.downloadFile(fileName);

return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + fileName + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}


@DeleteMapping
public ResponseEntity<BaseResponse> deleteFile(
@RequestParam("fileName") String fileName) {

log.info("DELETE /file — fileName='{}'", fileName);
fileStorageService.deleteFile(fileName);

return ResponseEntity.ok(
new BaseResponse(null,
"File [%s] deletion request submitted successfully.".formatted(fileName)));
}

}

Test the REST APIs

  • Upload Object
  • Retrieve objects by fileName
  • Delete object

Conclusion

In this post, we’ve explored Cloudflare R2 integration using Spring Boot as a Backend.

In production, file uploads should be handled using presigned URLs generated by the backend. This pattern leverages the S3-compatible capabilities of Cloudflare R2 to allow clients (web or mobile) to upload files directly to object storage without passing through the application server.

The backend is responsible for generating time-limited presigned URLs with restricted permissions (e.g., specific object key and content type). The client then uses this URL to perform a direct HTTP PUT or POST request to R2.

This architecture provides several advantages:

  • Reduced backend load: File transfer bypasses the application server, minimizing CPU and memory usage
  • Improved scalability: Upload throughput scales with the storage service rather than backend capacity
  • Enhanced performance: Clients communicate directly with R2, reducing latency
  • Security control: Presigned URLs are short-lived and scoped, limiting exposure and access

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for Reading !! See you in the next post.

References

👉 Link to Medium blog

Related Posts