跪拜 Guibai
← Back to the summary

Swap Alibaba Cloud OSS for MinIO and Save Thousands a Year on Object Storage

Background

File upload requirements come up constantly in outsourcing projects — user avatars, contract PDFs, Excel exports. Initially, Alibaba Cloud OSS was the easy choice, until one project where users uploaded several thousand contract scans and the monthly OSS bill hit over 200 yuan.

200 yuan isn't much on its own, but it adds up. Across three client projects, that's several thousand yuan a year. Meanwhile, the server already had 200 GB of idle disk space, so everything was migrated to a self-hosted MinIO setup.

What is MinIO

MinIO is open-source object storage compatible with the Amazon S3 API. Deploy it on your server — a single Docker container. The code that previously called S3 now calls MinIO the same way — the API is identical.

Docker Deployment

services:
  minio:
    image: minio/minio:latest
    container_name: minio
    ports:
      - "9000:9000"   # API port
      - "9001:9001"   # Console port
    volumes:
      - ./minio/data:/data
      - ./minio/config:/root/.minio
    environment:
      - MINIO_ROOT_USER=admin
      - MINIO_ROOT_PASSWORD=your_password_here
    command: server /data --console-address ":9001"
    networks:
      - aipdt-cloud
    restart: unless-stopped

After starting, log into the console at http://your-ip:9001 and create buckets. At least two are recommended: public for publicly accessible files (like avatars), and private for confidential files (contracts, ID documents).

Spring Boot Integration

Only one dependency:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>

Configuration:

minio:
  endpoint: http://minio:9000      # Use the service name directly inside containers
  access-key: admin
  secret-key: your_password_here
  bucket-public: public
  bucket-private: private

Configuration class:

@Configuration
public class MinioConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.access-key}")
    private String accessKey;
    @Value("${minio.secret-key}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

Utility Class

Encapsulate common operations in a utility class so business code can handle them in a single line:

@Slf4j
@Component
public class MinioUtil {
    @Autowired
    private MinioClient minioClient;

    // Upload file
    public String upload(String bucket, InputStream inputStream, 
                         String fileName, long size) {
        try {
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucket)
                    .object(fileName)
                    .stream(inputStream, size, -1)
                    .contentType(MediaType.APPLICATION_OCTET_STREAM_VALUE)
                    .build());
            return fileName;
        } catch (Exception e) {
            log.error("Upload failed", e);
            throw new RuntimeException("File upload failed");
        }
    }

    // Get file stream
    public InputStream download(String bucket, String fileName) {
        try {
            return minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucket)
                    .object(fileName)
                    .build());
        } catch (Exception e) {
            log.error("Download failed", e);
            throw new RuntimeException("File download failed");
        }
    }

    // Generate temporary access URL (valid for 7 days)
    public String presignedUrl(String bucket, String fileName) {
        try {
            return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .bucket(bucket)
                    .object(fileName)
                    .expiry(7, TimeUnit.DAYS)
                    .method(Method.GET)
                    .build());
        } catch (Exception e) {
            log.error("Failed to generate URL", e);
            return null;
        }
    }

    // Delete file
    public void delete(String bucket, String fileName) {
        try {
            minioClient.removeObject(RemoveObjectArgs.builder()
                    .bucket(bucket)
                    .object(fileName)
                    .build());
        } catch (Exception e) {
            log.error("Delete failed", e);
        }
    }
}

Business Code Examples

Upload avatar:

@PostMapping("/avatar")
public Result<String> uploadAvatar(@RequestParam MultipartFile file) {
    String fileName = "avatar/" + userId + ".jpg";
    minioUtil.upload(bucketPublic, file.getInputStream(), 
                     fileName, file.getSize());
    String url = minioUtil.presignedUrl(bucketPublic, fileName);
    return Result.ok(url);
}

Export Excel to MinIO:

// Generate Excel
ByteArrayOutputStream out = new ByteArrayOutputStream();
excelWriter.write(out);

// Upload to MinIO
ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
String fileName = "excel/" + UUID.randomUUID() + ".xlsx";
minioUtil.upload(bucketPrivate, in, fileName, out.size());

// Return download link
String downloadUrl = minioUtil.presignedUrl(bucketPrivate, fileName);

Access Policy

Public files: Avatars, cover images, etc. Set the bucket to public read:

mc policy set download myminio/public

Private files: Contracts, ID cards, etc. Keep the bucket private and control access via presigned URLs. The links carry an expiration time and signature, making them secure and controllable.

Pitfalls Encountered

1. Can't connect using localhost inside a container

When both MinIO and Spring Boot run in Docker, endpoint cannot use localhost. Use the container name http://minio:9000 or the host machine IP.

2. OOM when uploading large files

putObject reads the entire file into memory by default. Uploading files tens of MB in size will blow up memory. Switch to multipart upload:

minioClient.putObject(PutObjectArgs.builder()
        .bucket(bucket)
        .object(fileName)
        .stream(inputStream, -1, 5 * 1024 * 1024)  // 5 MB part size
        .build());

The third parameter -1 indicates unknown total size, and the fourth parameter 5M is the part size. This suits scenarios where the frontend uploads directly.

3. Domain issue with presigned URLs

MinIO generates links that default to http://internal-ip:9000/bucket/file. If the frontend needs to access them, the link must contain an external domain. Configure an nginx proxy:

location /minio/ {
    proxy_pass http://minio:9000/;
}

Or specify the environment variable MINIO_SERVER_URL=https://your-domain when starting MinIO.

When MinIO Is Not Suitable

If none of the above three conditions apply, MinIO is sufficient.

Summary

  1. MinIO replaces OSS, one-click Docker deployment, S3 API compatible.
  2. Two buckets — public and private — with public-read and presigned URL access modes.
  3. Use multipart upload for large files to avoid OOM.
  4. For small to medium projects without CDN or high-availability requirements, MinIO is fully adequate.
  5. Saves several thousand yuan per year in OSS fees while fully utilizing a single server's resources.

I am doing independent development in a "one person + AI" model. Search the WeChat mini-program "面狮狮" to experience AI-simulated interviews. More practical experience: https://gitee.com/yao113088/jiguang-dev

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

wfhusb

MinIO seems to be no longer maintained