Going Production: Spring Boot Best Practices

SS Saurav Sitaula

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 information
  • GET /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

  1. Start with profiles from day one. Retrofitting environment configs is painful.

  2. Logging is not optional. When production breaks at 3 AM, logs are your only friend.

  3. Document your API. Future you (and your team) will thank present you.

  4. Health checks save debugging time. “Is it the app or the database?” becomes obvious.

  5. 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:

  1. First API - Hello World with Spring Boot
  2. CRUD Operations - REST endpoints and validation
  3. Database - MyBatis and MySQL persistence
  4. Authentication - JWT and Spring Security
  5. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism