Micronaut #

Micronaut is a modern JVM framework designed from the start for cold-start times as fast as native images and the smallest possible memory footprint — without sacrificing the productivity developers expect from the Java ecosystem. Unlike Spring Boot, which uses reflection and classpath scanning at runtime, Micronaut performs dependency injection, AOP, and bean configuration at compile time via annotation processors. The result: millisecond startup, no runtime proxies, and no unnecessary lazy loading.

Docker Compose complements these strengths elegantly — you can run the entire stack (Micronaut app, Postgres, Redis) with one command, without manually installing Java, Maven, or backend services on the host. This article covers a complete Micronaut local-development setup: from multi-stage Dockerfiles, docker-compose.yml configuration with service dependencies, mn:run hot reload, to Micronaut Data, Flyway, and @MicronautTest for testing.

Prerequisites #

Make sure your local environment has:

  • Docker and Docker Compose (latest versions; Compose V2 is built into Docker Desktop)
  • Java 21+ (LTS) — Micronaut 4.x needs at least Java 17, but Java 21 is mainstream and more future-proof
  • Maven 3.8+ or Gradle 8+ (Gradle is faster for incremental builds, but Maven is more familiar to many enterprise teams)
  • The Micronaut CLI (mn) — for project scaffolding and task management

A standard Micronaut project created via mn create-app has a structure like this:

my-micronaut-app/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/example/
│   │   │       ├── Application.java
│   │   │       ├── controller/
│   │   │       ├── service/
│   │   │       └── repository/
│   │   └── resources/
│   │       ├── application.yml
│   │       ├── logback.xml
│   │       └── db/migration/
│   └── test/
│       └── java/
├── pom.xml
├── mvnw
├── mvnw.cmd
├── .mvn/
├── Dockerfile
├── docker-compose.yml
├── .env
└── .dockerignore

The Docker Philosophy for Micronaut #

Before getting into configuration, it’s important to understand: local development prioritizes the feedback loop, not runtime optimization. That means:

  • Large images are fine — a 300MB JDK is no problem on a developer laptop. Smallest-possible images are a production target.
  • Slower builds are fine — what matters is fast iteration (edit → reload → check).
  • Mounting source code directly is fine — so IDE changes show up in the container immediately.
  • Skipping multi-stage is fine — a single-stage dev Dockerfile with a JRE base and a direct mvn mn:run command works.

The main goals of Docker in Micronaut local development:

  • Environment consistency — all developers use the same Java version and libraries
  • Fast onboarding — new developers just run docker compose up and go
  • Fast iteration — hot reload with mn:run restarts in seconds
  • Automatic dependency setup — Postgres, Redis, and other services start alongside the app

Stack Architecture #

A Micronaut local-development setup usually involves:

  • The Micronaut app — the main service you’re developing
  • PostgreSQL — the most common database for Micronaut Data
  • Redis — for caching, sessions, or rate limiting
  • (Optional) Kafka — for event-driven architecture
  • (Optional) pgAdmin or Adminer — UIs for inspecting the database
flowchart TB
    subgraph Dev["Local Machine"]
        IDE[IDE - IntelliJ / VS Code]
    end

    subgraph Compose["Docker Compose Stack"]
        APP[Micronaut App<br/>mn:run :8080]
        DB[(PostgreSQL<br/>:5432)]
        CACHE[(Redis<br/>:6379)]
    end

    IDE -- edit & save --> APP
    APP -- JDBC --> DB
    APP -- Lettuce / Redis client --> CACHE

All services run on Compose’s internal network. The Micronaut app can resolve the db and cache hostnames directly without knowing IP addresses.

Multi-Stage Dockerfile #

Micronaut doesn’t have built-in Docker CLI tooling as rich as Quarkus, so you need to write your own Dockerfile. For local development, a single stage with a JDK base is enough — you need the JDK because Micronaut uses annotation processors, and mn:run runs a Maven goal. But since this article also covers production, we’ll make a multi-stage Dockerfile with a dev override.

# syntax=docker/dockerfile:1.6

# ============ Stage 1: Builder ============
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /build

# Extra tools for debugging and healthchecks
RUN apk add --no-cache bash curl

# Copy descriptors first for dependency layer caching
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw -B dependency:go-offline

# Copy source code and build the JAR
COPY src ./src
RUN ./mvnw -B package -DskipTests

# Extract the layered JAR (supported by Micronaut since 3.x)
RUN mkdir -p target/extracted && \
    cd target/extracted && \
    unzip -q ../*.jar

# ============ Stage 2: Runtime Production ============
FROM eclipse-temurin:21-jre-alpine AS runtime

WORKDIR /app

# Copy the layered JAR
COPY --from=builder /build/target/extracted/BOOT-INF/lib /app/lib
COPY --from=builder /build/target/extracted/BOOT-INF/classes /app/classes
COPY --from=builder /build/target/extracted/META-INF /app/META-INF

# Non-root user
RUN addgroup -S micronaut && adduser -S micronaut -G micronaut
USER micronaut:micronaut

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -cp 'classes:lib/*' com.example.Application"]

# ============ Stage 3: Development ============
FROM eclipse-temurin:21-jdk-alpine AS dev

WORKDIR /app

RUN apk add --no-cache bash curl

# Copy everything needed to run via Maven
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw

# Source code is mounted via a volume in Compose, so no COPY src needed
# BUT: pre-warm the dependency cache so the first build is fast
RUN ./mvnw -B dependency:go-offline || true

EXPOSE 8080

# Default command: run mn:run (overridden via the compose command)
CMD ["./mvnw", "mn:run"]

Explaining Each Stage #

Builder stage — contains the JDK and Maven for compilation. The dependency:go-offline layer caches all dependencies, so source changes don’t invalidate this layer. Subsequent builds only need compile and package, usually 10-30 seconds.

Runtime stage — the final image for production. JRE only, no Maven. The JAR is extracted into separate layers (lib, classes, META-INF) so the Docker cache can reuse the dependency layer when source code changes. This is the layered JAR optimization — Spring Boot introduced the pattern in 2.3, and Micronaut has supported it since 3.x.

Dev stage — a full JDK with the Maven wrapper. This is the image used for docker compose up locally. Unlike Spring Boot DevTools, which has in-JVM auto-restart, Micronaut relies on mn:run, which restarts the Maven process (not the JVM — slower than Spring DevTools, but fast enough for iteration).

Three stages, not one. Separating dev and runtime ensures the development image keeps the JDK and Maven (for mn:run), while the production image is as small as possible. Build with a specific target stage: docker build --target dev -t myapp:dev . or docker build --target runtime -t myapp:prod ..

docker-compose.yml for Development #

Here’s a complete setup with Postgres, Redis, healthchecks, and bind mounts for hot reload:

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: dev  # use the dev stage (not runtime)
    image: my-micronaut-app:dev
    container_name: micronaut-app
    command: ./mvnw mn:run
    volumes:
      - ./src:/app/src
      - ./pom.xml:/app/pom.xml
      - maven_cache:/root/.m2
      - ./target:/app/target
    ports:
      - "8080:8080"
      - "5005:5005"  # JDWP debug port
    environment:
      MICRONAUT_ENVIRONMENTS: dev
      MICRONAUT_DATASOURCES_DEFAULT_URL: jdbc:postgresql://db:5432/micronaut
      MICRONAUT_DATASOURCES_DEFAULT_USERNAME: app
      MICRONAUT_DATASOURCES_DEFAULT_PASSWORD: dev
      MICRONAUT_DATASOURCES_DEFAULT_DRIVER_CLASS_NAME: org.postgresql.Driver
      MICRONAUT_REDIS_URI: redis://cache:6379
      MICRONAUT_SERVER_PORT: 8080
      JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: micronaut-db
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: micronaut
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d micronaut"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: micronaut-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

  adminer:
    image: adminer:4.8.1
    container_name: micronaut-adminer
    ports:
      - "8081:8080"
    environment:
      ADMINER_DEFAULT_SERVER: db
    depends_on:
      - db

volumes:
  db-data:
  maven_cache:

Service Explanations #

app — the main Micronaut service. target: dev selects the dev stage from the Dockerfile (JDK + Maven). The mn:run command runs the application via the Maven plugin with auto-restart when source code changes. The ./src bind mount to /app/src keeps IDE changes in sync with the container. The maven_cache volume stores the .m2 repository in a named volume — otherwise dependencies would be re-downloaded on every container restart.

db — PostgreSQL 16. The pg_isready healthcheck ensures the database is truly accepting connections before app tries to connect. Init scripts in ./db/init (if any) run when the container first starts (not on restarts with existing data).

cache — Redis 7. The healthcheck uses redis-cli ping, which returns PONG when Redis is ready. Micronaut has the micronaut-redis-lettuce module for reactive Redis connections.

adminer — a web-based UI for inspecting the database. Access at http://localhost:8081, log in with the Postgres credentials. Can be replaced with pgAdmin or DBeaver (desktop-based).

Healthchecks and depends_on #

depends_on without condition: service_healthy only waits for containers to start, not for services to be ready. Postgres takes 2-5 seconds to initialize its cluster, so app could crash with “Connection refused” if it starts before Postgres is ready. Always use healthchecks for services with initialization time.

Hot Reload with mn:run #

Unlike Spring Boot DevTools or Quarkus Dev Mode, Micronaut doesn’t have instant hot reload. Restarts are handled by mn:run like this:

  1. Detect changes in the source code (polling every few seconds)
  2. Kill the old process (SIGTERM)
  3. Recompile with annotation processors
  4. Start a new process with the same classpath

Total restart time: 3-8 seconds for small-to-medium projects. Slower than Quarkus (~1 second) or JRebel, but fast enough for most development workflows.

pom.xml — dependencies for development #

<dependencies>
  <!-- Core Micronaut -->
  <dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-http-server-netty</artifactId>
  </dependency>

  <!-- Validation -->
  <dependency>
    <groupId>io.micronaut.validation</groupId>
    <artifactId>micronaut-validation</artifactId>
  </dependency>

  <!-- Data access -->
  <dependency>
    <groupId>io.micronaut.data</groupId>
    <artifactId>micronaut-data-jdbc</artifactId>
  </dependency>
  <dependency>
    <groupId>io.micronaut.sql</groupId>
    <artifactId>micronaut-jdbc-hikari</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Flyway for schema migration -->
  <dependency>
    <groupId>io.micronaut.flyway</groupId>
    <artifactId>micronaut-flyway</artifactId>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Redis -->
  <dependency>
    <groupId>io.micronaut.redis</groupId>
    <artifactId>micronaut-redis-lettuce</artifactId>
  </dependency>

  <!-- Management & health -->
  <dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-management</artifactId>
  </dependency>

  <!-- Logging -->
  <dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Test -->
  <dependency>
    <groupId>io.micronaut.test</groupId>
    <artifactId>micronaut-test-junit5</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

application.yml — configuration #

micronaut:
  application:
    name: my-micronaut-app
  server:
    port: 8080
  router:
    static-resources:
      default:
        enabled: true
        paths: classpath:public
  http:
    services:
      # default http client config

datasources:
  default:
    url: jdbc:postgresql://localhost:5432/micronaut
    username: app
    password: dev
    driver-class-name: org.postgresql.Driver
    schema-generate: NONE  # use Flyway
    dialect: POSTGRES
    maximum-pool-size: 10

flyway:
  datasources:
    default:
      enabled: true
      locations: classpath:db/migration
      baseline-on-migrate: true

redis:
  uri: redis://localhost:6379

endpoints:
  health:
    enabled: true
    sensitive: false
    details-visible: ANONYMOUS
  all:
    sensitive: false

jackson:
  serialization:
    indent-output: true
Override configuration via environment variables. Micronaut reads environment variables with patterns like MICRONAUT_DATASOURCES_DEFAULT_URL, REDIS_URI, etc. The Compose format above uses this approach so you don’t need to rebuild the image when configuration changes. For production, store config in a secrets manager or a mounted config file, not in Dockerfile environment variables.

Micronaut Data for Database Access #

Micronaut Data is a lightweight ORM inspired by GORM (Groovy) and Spring Data. Unlike Hibernate, which uses reflection, Micronaut Data compiles repository implementations at build time via annotation processors. The result: no startup overhead, no runtime proxies, and queries executed directly against JDBC.

Entity #

package com.example.entity;

import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.MappedProperty;
import io.micronaut.data.annotation.DateCreated;
import io.micronaut.data.annotation.DateUpdated;
import io.micronaut.serde.annotation.Serdeable;

import java.time.Instant;

@Serdeable
@MappedEntity("users")
public class User {

    @Id
    @GeneratedValue(GeneratedValue.Type.IDENTITY)
    private Long id;

    @MappedProperty("email")
    private String email;

    @MappedProperty("name")
    private String name;

    @DateCreated
    @MappedProperty("created_at")
    private Instant createdAt;

    @DateUpdated
    @MappedProperty("updated_at")
    private Instant updatedAt;

    // constructors, getters, setters
    public User() {}

    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

Repository #

package com.example.repository;

import com.example.entity.User;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;

import java.util.List;
import java.util.Optional;

@JdbcRepository(dialect = Dialect.POSTGRES)
public interface UserRepository extends CrudRepository<User, Long> {

    // Method-name query (compile-time)
    Optional<User> findByEmail(String email);

    List<User> findByNameContaining(String nameFragment);

    long countByEmail(String email);

    // Custom query with @Query
    @io.micronaut.data.annotation.Query("SELECT * FROM users WHERE created_at > :since")
    List<User> findRecentUsers(java.time.Instant since);
}

Controller #

package com.example.controller;

import com.example.entity.User;
import com.example.repository.UserRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;
import io.micronaut.validation.Validated;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

import java.util.List;

@Controller("/users")
@Validated
@Produces(MediaType.APPLICATION_JSON)
public class UserController {

    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Get
    public List<User> list() {
        return (List<User>) userRepository.findAll();
    }

    @Get("/{id}")
    public HttpResponse<User> get(Long id) {
        return userRepository.findById(id)
            .map(HttpResponse::ok)
            .orElse(HttpResponse.notFound());
    }

    @Post
    @Consumes(MediaType.APPLICATION_JSON)
    public HttpResponse<User> create(@Body @Valid UserRequest request) {
        User user = new User(request.email(), request.name());
        User saved = userRepository.save(user);
        return HttpResponse.created(saved);
    }

    public record UserRequest(
        @NotBlank @Email String email,
        @NotBlank String name
    ) {}
}

This pattern is very different from Spring Data JPA: no JpaRepository, no JPA @Entity, and no EntityManager injection. Micronaut Data generates the JDBC implementation directly at compile time.

Schema Migration with Flyway #

Flyway is the most mature schema migration tool in the JVM ecosystem. Micronaut has the micronaut-flyway module, which automatically runs migrations at startup.

Migration file structure #

src/main/resources/db/migration/
├── V1__create_users.sql
├── V2__create_orders.sql
└── V3__add_user_index.sql

V1__create_users.sql #

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

V2__create_orders.sql #

CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total_amount DECIMAL(10, 2) NOT NULL,
  status VARCHAR(50) NOT NULL DEFAULT 'pending',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

Flyway automatically reads SQL files in db/migration by version prefix (V1__, V2__, etc.) and runs them at application startup. The flyway_schema_history tracking table stores which migrations have run. To reset, remove the database volume:

docker compose down -v  # removes db-data
docker compose up -d
Don’t edit migrations already applied in production. Migrations must be immutable after deployment. To fix bugs, create a new migration (V4__fix_users_table.sql) doing an ALTER or data fix. This guarantees consistency between environments and prevents “works on my machine”.

Built-in Health Endpoints #

Micronaut has built-in health checks via the micronaut-management module. No extra dependency needed — the endpoint is immediately available at /health (or another path per configuration).

endpoints:
  health:
    enabled: true
    sensitive: false
    details-visible: ANONYMOUS

Available endpoints #

EndpointFunction
GET /healthOverall status (UP/DOWN)
GET /health/livenessLiveness probe — is the process alive
GET /health/readinessReadiness probe — is the app ready for traffic
GET /health/diskSpaceDisk usage details
GET /health/dbDatabase connection status
GET /health/redisRedis connection status

For Kubernetes, liveness and readiness are usually mapped to separate paths:

endpoints:
  health:
    probes:
      enabled: true
    liveness:
      enabled: true
    readiness:
      enabled: true

Custom health checks #

Add a HealthIndicator to check custom dependencies:

package com.example.health;

import io.micronaut.health.HealthStatus;
import io.micronaut.management.endpoint.health.HealthEndpoint;
import io.micronaut.management.health.indicator.HealthIndicator;
import io.micronaut.management.health.indicator.HealthResult;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

import java.util.Map;

@Singleton
public class ExternalApiHealthIndicator implements HealthIndicator {

    @Override
    public Publisher<HealthResult> getResult() {
        // call external API, return result
        boolean isUp = checkExternalApi();
        HealthResult result = isUp
            ? HealthResult.builder("external-api", HealthStatus.UP).build()
            : HealthResult.builder("external-api", HealthStatus.DOWN)
                .details(Map.of("error", "timeout")).build();
        return Mono.just(result);
    }

    private boolean checkExternalApi() {
        // implementation
        return true;
    }
}

Testing with @MicronautTest #

Micronaut Test is a JUnit 5 extension that simplifies integration testing. The @MicronautTest annotation boots the application context, and you can inject any bean you need.

Unit testing a service #

package com.example.service;

import com.example.entity.User;
import com.example.repository.UserRepository;
import io.micronaut.test.annotation.MockBean;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@MicronautTest
class UserServiceTest {

    @Inject
    UserService userService;

    @Inject
    UserRepository userRepository;

    @MockBean(UserRepository.class)
    UserRepository mockRepository() {
        return mock(UserRepository.class);
    }

    @Test
    void shouldFindUserByEmail() {
        User user = new User("[email protected]", "Test");
        when(userRepository.findByEmail("[email protected]"))
            .thenReturn(Optional.of(user));

        Optional<User> found = userService.findByEmail("[email protected]");

        assertTrue(found.isPresent());
        assertEquals("[email protected]", found.get().getEmail());
    }
}

Integration testing with Testcontainers #

package com.example;

import com.example.repository.UserRepository;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest
@Testcontainers
class UserRepositoryIntegrationTest implements TestPropertyProvider {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");

    @Inject
    UserRepository userRepository;

    @Override
    public Map<String, String> getProperties() {
        return Map.of(
            "datasources.default.url", postgres.getJdbcUrl(),
            "datasources.default.username", postgres.getUsername(),
            "datasources.default.password", postgres.getPassword()
        );
    }

    @Test
    void shouldSaveAndRetrieveUser() {
        var user = userRepository.save(new User("[email protected]", "Test"));

        var found = userRepository.findById(user.getId());

        assertTrue(found.isPresent());
        assertEquals("[email protected]", found.get().getEmail());
    }
}

Testcontainers automatically pulls the Postgres image, runs the container, and injects configuration via TestPropertyProvider. The container is shut down after the test class finishes.

Debugging from an IDE #

A Micronaut app in a container can be debugged from IntelliJ or VS Code. Set JAVA_TOOL_OPTIONS in Compose to enable JDWP:

environment:
  JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
ports:
  - "5005:5005"

In IntelliJ:

  1. Run → Edit Configurations → Add New → Remote JVM Debug
  2. Host: localhost, Port: 5005
  3. Command line arguments: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
  4. Set a breakpoint in the code
  5. Run → Debug

suspend=n means the JVM starts immediately without waiting for a debugger to attach. Good for development that doesn’t always need a debugger. To wait for attach (for debugging at startup), set suspend=y.

Build and Run #

# Build the image
docker compose build

# Run all services in the background
docker compose up -d

# View application logs (real-time)
docker compose logs -f app

# Restart only the app service (without rebuilding)
docker compose restart app

# Stop all services (Postgres data stays)
docker compose down

# Stop and remove volumes (reset the database)
docker compose down -v

# Enter the container for debugging
docker compose exec app sh

After docker compose up -d, access:

  • App: http://localhost:8080
  • Health check: http://localhost:8080/health
  • Adminer (DB UI): http://localhost:8081
  • Postgres: localhost:5432 (user app, password dev)
  • Redis: localhost:6379

Environment Profiles #

Micronaut uses the environment concept for per-stage configuration. The defaults are dev, test, prod. Set via the environment variable MICRONAUT_ENVIRONMENTS=dev,custom.

application.yml (default, production-like):

micronaut:
  application:
    name: my-micronaut-app

datasources:
  default:
    url: ${DATABASE_URL:`jdbc:postgresql://localhost:5432/micronaut`}
    username: ${DATABASE_USER:app}
    password: ${DATABASE_PASSWORD:dev}

application-dev.yml (development overrides):

datasources:
  default:
    url: jdbc:postgresql://db:5432/micronaut
    username: app
    password: dev

flyway:
  datasources:
    default:
      clean-disabled: false  # can drop & re-create during dev

logger:
  levels:
    com.example: DEBUG
    io.micronaut.data.query: DEBUG

application-prod.yml (production):

datasources:
  default:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
    maximum-pool-size: 20

logger:
  levels:
    root: WARN
    com.example: INFO

Activate a profile with MICRONAUT_ENVIRONMENTS=prod or --micronaut.environments=prod.

Best Practices #

Separate Dev and Prod Dockerfiles #

Use multi-stage with dev and runtime stages. The dev image contains the JDK and Maven; the prod image is just JRE + JAR. Avoid one image for both — a JDK in production is an extra attack surface.

Always Cache the Maven Repository #

volumes:
  - maven_cache:/root/.m2

A named volume for .m2 keeps the dependency cache across container restarts. Without it, Maven downloads hundreds of MB every time a container starts.

Use Healthchecks for Dependency Services #

Databases, Redis, and message brokers take time to become ready. depends_on: condition: service_healthy makes app wait for the healthcheck to pass, not just for the container to start.

Resource Limits for the JVM #

services:
  app:
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: "1.0"

The JVM doesn’t auto-detect Docker memory limits unless you use MaxRAMPercentage. Set it in the Dockerfile:

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport"

Don’t Mount target/ Whole #

volumes:
  - ./target:/app/target

Mounting target allows reusing compiled classes across container restarts, but can conflict with annotation processors writing to target/classes. If you get strange errors during mn:run, remove the target volume and let the container recompile.

Logging Configuration #

Micronaut logs to stdout/stderr by default. For development, set DEBUG or TRACE levels for specific packages:

logger:
  levels:
    com.example: DEBUG
    io.micronaut.http.server: DEBUG

In production, use structured JSON logs:

<dependency>
  <groupId>net.logstash.logback</groupId>
  <artifactId>logstash-logback-encoder</artifactId>
</dependency>

Use Layered JARs for Production #

Extract target/*.jar into lib, classes, and META-INF layers in the Dockerfile. The dependency layer (lib) rarely changes; the classes layer changes often. Docker caching makes rebuilds very fast.

Schema Migration in Production #

Don’t use schema-generate: CREATE_DROP or UPDATE in production. Always use Flyway or Liquibase with versioned migration files.

Troubleshooting #

Connection Refused to the Database #

app started before Postgres was ready. Make sure:

  • The db healthcheck is correct
  • depends_on: db: condition: service_healthy is set on app
  • Postgres logs show “ready to accept connections”

Hot Reload Not Working #

Check:

  • The ./src:/app/src volume is mounted
  • mn:run is actually running (not mvn package)
  • Files are changing on the host (check docker compose exec app ls -la /app/src)
  • Annotation processors aren’t erroring (check the logs for “annotation processing”)

Slow Maven Builds #

Usually because the dependency cache is missing. Make sure the maven_cache volume is mounted and not flushed:

docker compose down --remove-orphans  # DON'T use -v
docker compose up -d

OutOfMemoryError #

Three common causes:

  • Heap too small — raise JAVA_OPTS="-Xmx..." or use MaxRAMPercentage
  • Connection pool too large — lower maximum-pool-size in the datasource config
  • Container memory limit too small — raise it in deploy.resources.limits.memory

Check container memory usage:

docker stats micronaut-app

Health Check DOWN #

See details at /health with details-visible: ANONYMOUS (dev only). Usually:

  • Database connection failed — check inter-container networking
  • Redis not ready — check redis-cli ping in the container
  • Disk space exhausted — check df -h on the host

Micronaut with GraalVM Native Images #

Micronaut was designed for native image compilation. Unlike Spring Boot, which only added native support in version 3, Micronaut has been native-first from the start.

pom.xml:

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
</plugin>

Dockerfile for native builds:

FROM ghcr.io/graalvm/graalvm-ce:java21-22.3.0 AS builder

WORKDIR /build
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw dependency:go-offline

COPY src ./src
RUN ./mvnw package -DskipTests -Pnative

FROM alpine:3.19
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=builder /build/target/my-micronaut-app /app/app

EXPOSE 8080
ENTRYPOINT ["/app/app"]

Micronaut native images start in ~30ms (vs ~1-2 seconds for the JVM) with ~50MB memory (vs ~200MB for the JVM). Builds take longer (minutes), but runtime is very efficient — fitting for serverless and Kubernetes.

Micronaut Launch and the CLI #

For multi-profile builds, use the Micronaut Launch API or the mn CLI:

# Create an app with specific dependencies
mn create-app my-app \
  --features=data-jdbc,flyway,redis-lettuce,management

# Add a dependency
mn add-dependency micronaut-security-jwt

This CLI generates a project from official templates, then adds the chosen modules. Faster than manually editing pom.xml.

Distributed Configuration with Consul #

For centralized configuration, Micronaut supports Consul, Eureka, or Kubernetes ConfigMaps:

application.yml:

micronaut:
  config-client:
    enabled: true

consul:
  client:
    url: http://consul:8500
  config:
    enabled: true
    format: yaml
    path: config/my-micronaut-app
# docker-compose.yml
services:
  consul:
    image: hashicorp/consul:1.16
    ports:
      - "8500:8500"
  app:
    depends_on:
      - consul

Config in Consul takes priority over local files — fitting for configuration shared across many services.

Service Discovery with Consul #

micronaut:
  discovery-client:
    enabled: true

Every Micronaut app automatically registers with Consul at startup and looks up other services via Consul. Fits microservice architectures without hardcoding URLs.

Recap Cheatsheet #

PatternMicronaut Module
HTTP servermicronaut-http-server-netty
Validationmicronaut-validation
Databasemicronaut-data-jdbc, micronaut-jdbc-hikari
Schema migrationmicronaut-flyway
Cachemicronaut-redis-lettuce
Health & metricsmicronaut-management
Securitymicronaut-security-jwt
Reactivemicronaut-rxjava2/3
NativeGraalVM native image
Service discoverymicronaut-discovery-client
Distributed configmicronaut-config-client
Tracingmicronaut-tracing-opentelemetry
Testingmicronaut-test-junit5
CLImn (Micronaut CLI)

Summary #

  • Micronaut is ideal for lightweight, fast JVM local development, with millisecond startup and a small memory footprint.
  • Multi-stage Dockerfiles with a dev stage (JDK + Maven) for development and a runtime stage (JRE + layered JAR) for production. Build with target: dev in Compose, target: runtime for the production image.
  • Hot reload with mn:run restarts the Maven process when source code changes, taking 3-8 seconds total. Slower than Quarkus but enough for iteration.
  • docker-compose.yml with Postgres, Redis, and Adminer. Use healthchecks + depends_on: condition: service_healthy for correct startup.
  • The Maven cache uses the named volume maven_cache:/root/.m2 so dependencies aren’t re-downloaded on every container restart.
  • Micronaut Data is a lightweight ORM with compile-time repository generation, no reflection or runtime proxies like Hibernate.
  • Flyway for schema migration with versioned SQL files (V1__, V2__, etc.) in src/main/resources/db/migration/. Runs automatically at application startup.
  • Built-in health endpoints via micronaut-management: /health, /health/liveness, /health/readiness, /health/db, /health/redis. Fits Kubernetes probes.
  • Testing with @MicronautTest + Testcontainers for integration tests. TestPropertyProvider injects container configuration into the test context.
  • IDE debugging via JDWP port 5005 with JAVA_TOOL_OPTIONS=-agentlib:jdwp=.... IntelliJ/VS Code attach as a Remote JVM Debug.
  • Environment profiles with application-{env}.yml and MICRONAUT_ENVIRONMENTS=dev,prod for per-stage configuration.
  • Resource limits matter for the JVM: set JAVA_OPTS=-XX:MaxRAMPercentage=75.0 and deploy.resources.limits.memory in Compose.
  • Best practices: separate dev/prod Dockerfiles, cache .m2, healthchecks for dependencies, layered JARs for production, Flyway for migrations, resource limits.
  • Troubleshooting: connection refused (healthcheck), hot reload (check volume mounts), slow builds (Maven cache), OOM (resource limits).
  • Layered JAR optimization — extract target/*.jar into lib, classes, META-INF in the Dockerfile for optimal Docker caching.
  • Custom health checks — implement HealthIndicator for external dependency checks. Built-ins for databases, Redis, and disk space.
  • Production readiness with the prod profile, multi-stage images, health probes, resource limits, and structured JSON logging.

← Previous: Quarkus   Next: Gin →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact