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:runcommand 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 upand go - Fast iteration — hot reload with
mn:runrestarts 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 --> CACHEAll 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. Separatingdevandruntimeensures the development image keeps the JDK and Maven (formn:run), while the production image is as small as possible. Build with a specific target stage:docker build --target dev -t myapp:dev .ordocker 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:
- Detect changes in the source code (polling every few seconds)
- Kill the old process (SIGTERM)
- Recompile with annotation processors
- 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 likeMICRONAUT_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 #
| Endpoint | Function |
|---|---|
GET /health | Overall status (UP/DOWN) |
GET /health/liveness | Liveness probe — is the process alive |
GET /health/readiness | Readiness probe — is the app ready for traffic |
GET /health/diskSpace | Disk usage details |
GET /health/db | Database connection status |
GET /health/redis | Redis 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:
- Run → Edit Configurations → Add New → Remote JVM Debug
- Host:
localhost, Port:5005 - Command line arguments:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 - Set a breakpoint in the code
- 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(userapp, passworddev) - 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
dbhealthcheck is correct depends_on: db: condition: service_healthyis set onapp- Postgres logs show “ready to accept connections”
Hot Reload Not Working #
Check:
- The
./src:/app/srcvolume is mounted mn:runis actually running (notmvn 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 useMaxRAMPercentage - Connection pool too large — lower
maximum-pool-sizein 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 pingin the container - Disk space exhausted — check
df -hon 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 #
| Pattern | Micronaut Module |
|---|---|
| HTTP server | micronaut-http-server-netty |
| Validation | micronaut-validation |
| Database | micronaut-data-jdbc, micronaut-jdbc-hikari |
| Schema migration | micronaut-flyway |
| Cache | micronaut-redis-lettuce |
| Health & metrics | micronaut-management |
| Security | micronaut-security-jwt |
| Reactive | micronaut-rxjava2/3 |
| Native | GraalVM native image |
| Service discovery | micronaut-discovery-client |
| Distributed config | micronaut-config-client |
| Tracing | micronaut-tracing-opentelemetry |
| Testing | micronaut-test-junit5 |
| CLI | mn (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
devstage (JDK + Maven) for development and aruntimestage (JRE + layered JAR) for production. Build withtarget: devin Compose,target: runtimefor the production image.- Hot reload with
mn:runrestarts 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_healthyfor correct startup.- The Maven cache uses the named volume
maven_cache:/root/.m2so 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.) insrc/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.TestPropertyProviderinjects 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}.ymlandMICRONAUT_ENVIRONMENTS=dev,prodfor per-stage configuration.- Resource limits matter for the JVM: set
JAVA_OPTS=-XX:MaxRAMPercentage=75.0anddeploy.resources.limits.memoryin 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/*.jarintolib,classes,META-INFin the Dockerfile for optimal Docker caching.- Custom health checks — implement
HealthIndicatorfor external dependency checks. Built-ins for databases, Redis, and disk space.- Production readiness with the
prodprofile, multi-stage images, health probes, resource limits, and structured JSON logging.