Back to Blog
spring-bootfile-uploads3awsjavastorage

File Upload in Spring Boot: From Multipart to S3 at Scale (2026)

Storing uploaded files in the database or on the application server is fine for demos. Production apps need streaming uploads, virus scanning, and cloud storage. Here's the full guide.

J

JOptimize Team

May 30, 2026· 8 min read

The tutorial version of file upload — receive the file, save it to the filesystem, return a URL — breaks in production for several reasons: application servers restart and lose files, horizontal scaling means files are on different pods, large files exhaust memory, and there's no virus scanning. Here's how to do it properly.


The Right Architecture

Production file uploads have three components:

  1. Client → Application Server — multipart HTTP upload with validation
  2. Application Server → Object Storage — stream the file to S3 (or equivalent) without buffering it entirely in memory
  3. Serving — generate presigned S3 URLs so clients download directly from S3, not through your app

This architecture means your application server is never a bottleneck for large file transfers. S3 handles storage, CDN, and bandwidth.


Configuration

# application.yml spring: servlet: multipart: max-file-size: 50MB # Max single file max-request-size: 100MB # Max total request file-size-threshold: 2MB # Above this: write to temp file instead of memory location: /tmp/uploads # Temp directory for large files
<dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> <version>2.25.0</version> </dependency>

File Upload Controller

@RestController @RequestMapping("/api/v1/files") @RequiredArgsConstructor public class FileUploadController { private final FileUploadService uploadService; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity<FileUploadResponse> upload( @RequestPart("file") MultipartFile file, @RequestPart("metadata") @Valid FileMetadataRequest metadata, @AuthenticationPrincipal UserDetails user) { // Validate file before processing fileValidator.validate(file); FileUploadResponse response = uploadService.upload(file, metadata, user.getUsername()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } // Presigned URL for direct client download from S3 @GetMapping("/{fileId}/download-url") public PresignedUrlResponse getDownloadUrl( @PathVariable UUID fileId, @AuthenticationPrincipal UserDetails user) { return uploadService.generateDownloadUrl(fileId, user.getUsername()); } }

File Validation

@Component public class FileValidator { private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of( "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" // xlsx ); private static final long MAX_FILE_SIZE = 50L * 1024 * 1024; // 50MB public void validate(MultipartFile file) { if (file.isEmpty()) { throw new InvalidFileException("File is empty"); } if (file.getSize() > MAX_FILE_SIZE) { throw new InvalidFileException("File exceeds maximum size of 50MB"); } // Detect MIME type from content, not just the Content-Type header // Users can set Content-Type to anything — content detection is safer String detectedType = detectMimeType(file); if (!ALLOWED_CONTENT_TYPES.contains(detectedType)) { throw new InvalidFileException("File type not allowed: " + detectedType); } // Sanitize filename — prevent path traversal attacks String originalName = file.getOriginalFilename(); if (originalName != null && (originalName.contains("..") || originalName.contains("/"))) { throw new InvalidFileException("Invalid filename"); } } private String detectMimeType(MultipartFile file) { try { Tika tika = new Tika(); // Apache Tika — detects from content, not extension return tika.detect(file.getInputStream()); } catch (IOException e) { throw new InvalidFileException("Cannot read file content"); } } }

Streaming to S3

@Service @RequiredArgsConstructor @Slf4j public class FileUploadService { private final S3Client s3Client; private final FileMetadataRepository fileRepo; @Value("${aws.s3.bucket}") private String bucket; public FileUploadResponse upload(MultipartFile file, FileMetadataRequest meta, String uploadedBy) { String key = generateKey(uploadedBy, file.getOriginalFilename()); try { // Stream directly to S3 — no buffering in memory PutObjectRequest request = PutObjectRequest.builder() .bucket(bucket) .key(key) .contentType(file.getContentType()) .contentLength(file.getSize()) .metadata(Map.of( "original-name", sanitizeFilename(file.getOriginalFilename()), "uploaded-by", uploadedBy )) .build(); s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); } catch (IOException e) { throw new FileUploadException("Failed to read file", e); } catch (S3Exception e) { log.error("S3 upload failed for key {}: {}", key, e.getMessage()); throw new FileUploadException("Storage upload failed", e); } // Save metadata to database FileMetadata saved = fileRepo.save(FileMetadata.builder() .id(UUID.randomUUID()) .s3Key(key) .originalName(file.getOriginalFilename()) .contentType(file.getContentType()) .sizeBytes(file.getSize()) .uploadedBy(uploadedBy) .uploadedAt(Instant.now()) .build()); return new FileUploadResponse(saved.getId(), saved.getOriginalName()); } public PresignedUrlResponse generateDownloadUrl(UUID fileId, String requestedBy) { FileMetadata file = fileRepo.findById(fileId).orElseThrow(); // Verify ownership or admin role if (!file.getUploadedBy().equals(requestedBy) && !isAdmin(requestedBy)) { throw new AccessDeniedException("Access denied"); } S3Presigner presigner = S3Presigner.create(); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(15)) // URL valid for 15 minutes .getObjectRequest(r -> r.bucket(bucket).key(file.getS3Key())) .build(); PresignedGetObjectRequest presigned = presigner.presignGetObject(presignRequest); return new PresignedUrlResponse(presigned.url().toString(), 15 * 60); } private String generateKey(String userId, String originalName) { String extension = getExtension(originalName); return "uploads/" + userId + "/" + UUID.randomUUID() + extension; // Result: uploads/user123/550e8400-e29b-41d4-a716-446655440000.pdf } }

Multipart Upload for Large Files (> 100MB)

For very large files, S3 multipart upload handles network interruptions and allows parallel chunk uploads:

public void multipartUpload(InputStream inputStream, String key, long totalSize) { CreateMultipartUploadResponse upload = s3Client.createMultipartUpload( r -> r.bucket(bucket).key(key)); String uploadId = upload.uploadId(); List<CompletedPart> completedParts = new ArrayList<>(); byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB chunks int partNumber = 1; int bytesRead; try { while ((bytesRead = inputStream.read(buffer)) > 0) { UploadPartResponse part = s3Client.uploadPart( r -> r.bucket(bucket).key(key) .uploadId(uploadId).partNumber(partNumber), RequestBody.fromBytes(Arrays.copyOf(buffer, bytesRead))); completedParts.add(CompletedPart.builder() .partNumber(partNumber).eTag(part.eTag()).build()); partNumber++; } s3Client.completeMultipartUpload(r -> r.bucket(bucket).key(key) .uploadId(uploadId) .multipartUpload(m -> m.parts(completedParts))); } catch (Exception e) { s3Client.abortMultipartUpload(r -> r.bucket(bucket).key(key).uploadId(uploadId)); throw new FileUploadException("Multipart upload failed", e); } }

Common Mistakes to Avoid

  • Storing files on the application server — files are lost on pod restart; use object storage (S3, GCS, Azure Blob)
  • Loading the entire file into memoryfile.getBytes() loads everything; use file.getInputStream() and stream to S3
  • Trusting the Content-Type header — users can send Content-Type: image/jpeg for a PHP script; always detect MIME type from file content with Apache Tika
  • Serving files through the application — generating a presigned S3 URL and letting clients download directly is faster, cheaper, and doesn't consume app threads

Summary

Production file upload in Spring Boot: validate file type from content (not headers), stream directly to S3 with putObject + InputStream (no memory buffering), store metadata in the database, and generate presigned S3 URLs for client downloads. For files over 100MB, use S3 multipart upload. Never store files on application servers or load entire files into memory.


Analyze Your Spring Boot App for Performance

File upload patterns — storing BLOBs in PostgreSQL, loading files into heap — are common performance issues. JOptimize flags them alongside N+1 queries and missing pagination.

Upload at scale. Store correctly.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.