Building a Blog with Image Upload Using Spring Boot and Vue.js
This article provides a complete solution for implementing image upload, storage, and display in a blog built with Spring Boot and Vue.js. It covers configuring an image server on a cloud instance, the backend API, the frontend component, and database storage.
1. Image Server Setup (Ubuntu)
1.1 Option Selection
- Self-hosted image server: Use Nginx to serve static resources.
- Cloud storage service: Use object storage like Alibaba Cloud OSS (recommended for CDN acceleration).
1.2 Steps for Self-hosted Image Server
# Install Nginx
sudo apt update && sudo apt install nginx
# Create directory for images
sudo mkdir -p /var/www/images/blog
sudo chmod -R 777 /var/www/images/blog
# Configure Nginx (/etc/nginx/sites-available/default)
server {
listen 80;
server_name your-domain.com;
location /images/ {
root /var/www;
autoindex on; # allow directory listing (testing only, disable in production)
expires 30d; # caching optimization
}
}
# Restart Nginx
sudo systemctl restart nginx
Images will be accessible via http://server-ip/images/blog/filename.
1.3 Security Optimizations
- Use HTTPS (free certificate from Let's Encrypt).
- Restrict file types: Add
if ($request_filename ~* \.(php|jsp)) { deny all; }in the Nginx config. - Hotlinking protection: Add
valid_referers blocked server_names; if ($invalid_referer) { return 403; }.
2. Spring Boot Backend
2.1 File Upload Endpoint
@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Value("${file.upload-dir}")
private String uploadPath;
@PostMapping("/image")
public ResponseEntity<Map<String, String>> uploadImage(
@RequestParam("file") MultipartFile file) {
// Generate unique filename
String originalName = file.getOriginalFilename();
String fileExt = FilenameUtils.getExtension(originalName);
String newFileName = UUID.randomUUID() + "." + fileExt;
// Save file
Path targetPath = Paths.get(uploadPath).resolve(newFileName);
try {
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException("File upload failed");
}
// Return accessible URL
String imageUrl = "https://your-domain.com/images/blog/" + newFileName;
return ResponseEntity.ok(Collections.singletonMap("url", imageUrl));
}
}
2.2 Configuration for Upload Path
# application.yml
file:
upload-dir: /var/www/images/blog
2.3 Cross-Origin Configuration
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080") // Vue frontend URL
.allowedMethods("*");
}
}
3. Vue.js Frontend
3.1 Image Upload Component (Using Element Plus)
<template>
<el-upload
action="#"
:http-request="customUpload"
:show-file-list="false"
:before-upload="beforeUpload">
<el-button type="primary">Upload Image</el-button>
</el-upload>
</template>
<script setup>
import axios from 'axios';
import { ElMessage } from 'element-plus';
const customUpload = async ({ file }) => {
const formData = new FormData();
formData.append('file', file);
try {
const res = await axios.post('/api/upload/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Insert returned URL into rich text editor
editor.insertEmbed(editor.getSelection().index, 'image', res.data.url);
} catch (err) {
console.error('Upload failed', err);
ElMessage.error('Upload failed');
}
};
const beforeUpload = (file) => {
const isImage = ['image/jpeg', 'image/png'].includes(file.type);
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isImage) {
ElMessage.error('Only JPG/PNG formats are supported');
return false;
}
if (!isLt5M) {
ElMessage.error('Image size must be less than 5MB');
return false;
}
return true;
};
</script>
3.2 Rich Text Editor Integration (Using Quill)
// Initialize the editor
const editor = new Quill('#editor', {
modules: {
toolbar: [
['bold', 'image'] // Add image button
]
}
});
// Listen for image button click
editor.getModule('toolbar').addHandler('image', () => {
document.querySelector('.el-upload input').click();
});
4. Database Storage
4.1 Blog Table Structure
CREATE TABLE blog (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
content LONGTEXT NOT NULL, -- Stores HTML containing <img src="URL">
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
4.2 JPA Entity
@Entity
public class Blog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Lob // for large text
private String content;
@CreationTimestamp
private LocalDateTime createdAt;
// getters and setters
}
5. Deployment Considerations
- Server permissions: Ensure the Spring Boot process has write permissions:
sudo chown -R springboot_user:springboot_user /var/www/images. - Periodic cleanup: Set up a cron job to delete unused images older than 30 days.
- Backup strategy: Use
rsyncto synchronize the image directory to a backup server. - Monitoring and alerting: Monitor storage usage with Prometheus.
Extension: Using Alibaba Cloud OSS
- Create an OSS bucket, obtain endpoint and access keys.
- Add the SDK dependency:
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
- Modify the upload logic:
// Create OSS client
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// Upload file stream
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(file.getBytes()));
// Generate signed URL (valid for 1 hour)
String url = ossClient.generatePresignedUrl(bucketName, objectName,
new Date(System.currentTimeMillis() + 3600 * 1000)).toString();
The cloud storage option provides automatic scaling, CDN aceleration, and access statistics, making it suitable for high-traffic scenarios.
Both the self-hosted and cloud storage approaches follow the same flow: frontend upload → backend processing → URL storage → content rendering, achieving a complete image management feature.