Going Production: Spring Boot Best Practices
From localhost to production. Error handling, logging, environment configs, API documentation, health checks, and the lessons learned from deploying my first Spring Boot API. Everything I wish I knew before going live.
The “It Works on My Machine” Problem
After securing my API with JWT, I was ready to deploy. How hard could it be? Push to server, run the JAR file, done.
Narrator: It was not done.
My first deployment was a disaster:
- Hardcoded database credentials (oops)
- No error handling (500 errors with full stack traces)
- No logs (what’s happening??)
- No documentation (how does this endpoint work again?)
- No health checks (is it even running?)
This post is everything I learned fixing those problems.
Environment Configuration
My application.properties had this:
spring.datasource.url=jdbc:mysql://localhost:3306/myapi_db
spring.datasource.username=root
spring.datasource.password=supersecret123
jwt.secret=my-super-secret-key
Pushed to GitHub. Credentials visible. panic
The Solution: Profiles and Environment Variables
Spring Boot supports profiles for different environments:
application.properties (base config)
server.port=8080
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.configuration.map-underscore-to-camel-case=true
application-dev.properties (development)
spring.datasource.url=jdbc:mysql://localhost:3306/myapi_dev
spring.datasource.username=root
spring.datasource.password=devpassword
jwt.secret=dev-secret-not-for-production
jwt.expiration=86400000
logging.level.root=DEBUG
application-prod.properties (production)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USER}
spring.datasource.password=${DATABASE_PASSWORD}
jwt.secret=${JWT_SECRET}
jwt.expiration=3600000
logging.level.root=INFO
Run with profile:
# Development
java -jar myapi.jar --spring.profiles.active=dev
# Production
DATABASE_URL=jdbc:mysql://prod-server:3306/myapi \
DATABASE_USER=produser \
DATABASE_PASSWORD=prodpass \
JWT_SECRET=super-long-random-production-secret \
java -jar myapi.jar --spring.profiles.active=prod
No more credentials in code. Environment variables for sensitive data.
Global Error Handling
Before:
{
"timestamp": "2020-09-15T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException\n\tat com.mycompany...",
"path": "/api/users/1"
}
Stack traces in production? That’s both ugly and a security risk.
After, with a global exception handler:
package com.mycompany.myapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Custom exception for "not found"
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
ApiError error = new ApiError(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
Map<String, Object> response = new HashMap<>();
response.put("status", 400);
response.put("message", "Validation failed");
response.put("errors", fieldErrors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.badRequest().body(response);
}
// Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneral(Exception ex) {
// Log the full error server-side
log.error("Unexpected error", ex);
// Return generic message to client
ApiError error = new ApiError(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
The custom exception:
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
The error response class:
public class ApiError {
private int status;
private String message;
private LocalDateTime timestamp;
// constructor, getters
}
Now errors are consistent and safe:
{
"status": 404,
"message": "User not found with id: 999",
"timestamp": "2020-09-15T10:30:00"
}
Logging
System.out.println() is not logging. Here’s proper logging:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
public User create(User user) {
log.info("Creating user with email: {}", user.getEmail());
try {
userMapper.insert(user);
log.debug("User created with ID: {}", user.getId());
return user;
} catch (Exception e) {
log.error("Failed to create user: {}", user.getEmail(), e);
throw e;
}
}
}
Configure logging levels in application.properties:
# Root level
logging.level.root=INFO
# Package-specific
logging.level.com.mycompany.myapi=DEBUG
logging.level.com.mycompany.myapi.security=TRACE
# SQL queries (useful for debugging)
logging.level.org.mybatis=DEBUG
# Output to file
logging.file.name=logs/myapi.log
logging.file.max-size=10MB
logging.file.max-history=30
Log levels (from most to least verbose):
- TRACE: Very detailed
- DEBUG: Detailed debugging
- INFO: General information
- WARN: Potential issues
- ERROR: Errors that need attention
API Documentation with Swagger/OpenAPI
My React teammate: “What endpoints are available?” Me: “Uh… check the controller?”
That’s not professional. Enter Swagger:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.14</version>
</dependency>
That’s it. Run your app and visit http://localhost:8080/swagger-ui.html.
Auto-generated interactive documentation. Every endpoint, every model, testable right in the browser.
Make it better with annotations:
@RestController
@RequestMapping("/api/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {
@Operation(
summary = "Get all users",
description = "Returns a list of all registered users"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Success"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@Operation(summary = "Create a new user")
@PostMapping
public ResponseEntity<User> createUser(
@Parameter(description = "User data") @Valid @RequestBody CreateUserRequest request
) {
// ...
}
}
Configure the API info:
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("My API")
.version("1.0.0")
.description("REST API for my React application")
.contact(new Contact()
.name("Saurav Sitaula")
.email("saurav@example.com")));
}
}
For JWT-protected endpoints:
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(...)
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
Now you can authorize in Swagger UI and test protected endpoints directly.
Health Checks with Actuator
Is the API running? Is the database connected? Add Actuator:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Configure in application.properties:
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when_authorized
Now you have:
GET /actuator/health- Is the app healthy?GET /actuator/info- App informationGET /actuator/metrics- Performance metrics
Health check response:
{
"status": "UP",
"components": {
"db": {
"status": "UP",
"details": {
"database": "MySQL"
}
},
"diskSpace": {
"status": "UP",
"details": {
"total": 499963174912,
"free": 234567891234
}
}
}
}
Load balancers and monitoring tools can ping /actuator/health to check if your service is alive.
Request/Response Logging
For debugging, log every request and response:
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
long startTime = System.currentTimeMillis();
String requestId = UUID.randomUUID().toString().substring(0, 8);
log.info("[{}] --> {} {} from {}",
requestId,
request.getMethod(),
request.getRequestURI(),
request.getRemoteAddr()
);
filterChain.doFilter(request, response);
long duration = System.currentTimeMillis() - startTime;
log.info("[{}] <-- {} {} ({}ms)",
requestId,
response.getStatus(),
request.getRequestURI(),
duration
);
}
}
Output:
[a1b2c3d4] --> GET /api/users from 192.168.1.100
[a1b2c3d4] <-- 200 /api/users (45ms)
Rate Limiting
Prevent abuse with rate limiting. Using Bucket4j:
<dependency>
<groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
<artifactId>bucket4j-spring-boot-starter</artifactId>
<version>0.7.0</version>
</dependency>
bucket4j.enabled=true
bucket4j.filters[0].cache-name=buckets
bucket4j.filters[0].url=/api/.*
bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=100
bucket4j.filters[0].rate-limits[0].bandwidths[0].time=1
bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes
100 requests per minute per IP. More than that? 429 Too Many Requests.
The Production Checklist
Before deploying, I now check:
- No hardcoded secrets - Use environment variables
- Profiles configured - Dev vs Prod settings
- Error handling - Global exception handler
- Logging - Structured, with appropriate levels
- API documentation - Swagger/OpenAPI
- Health checks - Actuator enabled
- CORS configured - For your frontend domain
- Input validation - On all endpoints
- Authentication - JWT or session security
- HTTPS - SSL/TLS in production
- Rate limiting - Prevent abuse
- Database migrations - Flyway or Liquibase
Building the JAR
# Clean build
./mvnw clean package -DskipTests
# The JAR is in target/
ls target/myapi-1.0.0.jar
# Run it
java -jar target/myapi-1.0.0.jar --spring.profiles.active=prod
The JAR file contains everything: your code, dependencies, and an embedded Tomcat server. One file to deploy.
What I Wish I’d Known Earlier
-
Start with profiles from day one. Retrofitting environment configs is painful.
-
Logging is not optional. When production breaks at 3 AM, logs are your only friend.
-
Document your API. Future you (and your team) will thank present you.
-
Health checks save debugging time. “Is it the app or the database?” becomes obvious.
-
Security is not a feature—it’s a requirement. HTTPS, input validation, rate limiting. Always.
The Journey Complete
What started as “my React app needs data” became a fully functional, production-ready backend:
- First API - Hello World with Spring Boot
- CRUD Operations - REST endpoints and validation
- Database - MyBatis and MySQL persistence
- Authentication - JWT and Spring Security
- Production Ready - Logging, docs, monitoring
I’m not a “backend developer” now. But I understand what’s happening behind those API calls. When the backend team says “it’s a 500 from the database connection pool,” I know what they mean.
And my React apps? They have real backends. Real persistence. Real security. Built by me.
P.S. — Backend development seemed scary from the frontend side. All those config files, XML, abstract classes. But Spring Boot genuinely makes it approachable. If you’re a frontend dev thinking about learning backend, give it a shot. The first API is the hardest. After that, it’s just variations on the same theme.
Saurav Sitaula
Software Architect • Nepal