Back to Blog
Tutorial6 min read2026-07-12

How to Deploy a Micronaut App with PostgreSQL on PandaStack

Build the shadow JAR, map DATABASE_URL to Micronaut's datasource config, run Flyway migrations, and go live with a Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Micronaut earned its place in the JVM world by doing dependency injection at compile time instead of runtime — which means fast startup, low memory, and no reflection-heavy classpath scanning when your container boots. That makes it a genuinely good fit for container platforms. But between mn create-app and a production deployment there are a handful of specifics worth getting right: which JAR to run, how environments resolve, and how to feed a platform-provided DATABASE_URL into Micronaut's datasource config. Here's the whole path.

Building Micronaut for production

A Micronaut project generated from [Micronaut Launch](https://micronaut.io/launch/) with Gradle uses the Shadow plugin, so a plain build produces a self-contained executable JAR:

./gradlew build

The artifact you want lands in build/libs/ with an -all suffix — that's the shadow JAR with every dependency merged in:

java -jar build/libs/myapp-0.1-all.jar

There will be two or three JARs in that directory. The one without -all is just your classes and won't run standalone. Grabbing the wrong one is the Micronaut equivalent of Quarkus users copying only the launcher jar — the container exits immediately with a NoClassDefFoundError and you spend twenty minutes confused.

For Maven projects, ./mvnw package produces the runnable JAR in target/ via the Micronaut Maven plugin; everything else in this post applies unchanged.

In CI you'll want to skip tests during the image build (run them as their own step instead):

./gradlew build -x test --no-daemon

The --no-daemon flag matters in containers — the Gradle daemon is a long-lived background process that buys you nothing in a throwaway build pod and holds memory hostage.

Port and server configuration

Micronaut's embedded Netty server listens on 8080 and binds to all interfaces by default, so containers route to it without extra work. If your platform injects a PORT variable, map it with a property placeholder:

# src/main/resources/application.yml
micronaut:
  application:
    name: myapp
  server:
    port: ${PORT:8080}

Alternatively, Micronaut maps environment variables to configuration keys automatically — MICRONAUT_SERVER_PORT=8080 sets micronaut.server.port. This convention (uppercase, dots become underscores) works for *any* configuration property, and it's the mechanism we'll lean on for the database below.

Environments: how prod config resolves

Micronaut has first-class configuration environments. Set one explicitly in production:

MICRONAUT_ENVIRONMENTS=prod

Now application-prod.yml is layered on top of application.yml, and anything in the prod file wins. This is where I put connection pool sizing, log levels, and anything else that differs from local dev:

# src/main/resources/application-prod.yml
datasources:
  default:
    maximum-pool-size: 10

That pool size deserves a sentence: managed Postgres plans meter connections (PandaStack's free tier allows 50, Pro 300), and HikariCP's defaults plus a few replicas can eat that faster than you'd think. Size the pool deliberately: replicas × pool size must stay under the plan limit with room for migrations and a psql session.

Configuring PostgreSQL

Add the JDBC and Postgres dependencies (Micronaut Launch does this if you pick the postgres and jdbc-hikari features):

// build.gradle.kts
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
runtimeOnly("org.postgresql:postgresql")

And the datasource block:

# application.yml
datasources:
  default:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/dev
    username: dev
    password: dev

In production you override these via the env-var convention — no YAML changes needed:

DATASOURCES_DEFAULT_URL=jdbc:postgresql://host:5432/dbname
DATASOURCES_DEFAULT_USERNAME=...
DATASOURCES_DEFAULT_PASSWORD=...

Bridging DATABASE_URL to JDBC

The catch: managed platforms inject a single DATABASE_URL shaped like postgres://user:password@host:5432/dbname, and JDBC refuses that format. It wants the jdbc:postgresql:// scheme and separate credentials. A short entrypoint script converts one to the other before the JVM starts:

#!/bin/sh
# entrypoint.sh
if [ -n "$DATABASE_URL" ]; then
  stripped="${DATABASE_URL#*://}"
  userpass="${stripped%%@*}"
  hostpart="${stripped#*@}"
  export DATASOURCES_DEFAULT_USERNAME="${userpass%%:*}"
  export DATASOURCES_DEFAULT_PASSWORD="${userpass#*:}"
  export DATASOURCES_DEFAULT_URL="jdbc:postgresql://${hostpart}"
fi
exec java -jar /app/app.jar

Because Micronaut's env-var mapping is generic, this is all it takes — the exported variables land directly on datasources.default.* and the YAML defaults only apply locally.

Flyway migrations

For schema management, add the Flyway module — and note that since Flyway 10, Postgres support is a separate artifact, which is a genuinely easy dependency to miss:

implementation("io.micronaut.flyway:micronaut-flyway")
runtimeOnly("org.flywaydb:flyway-database-postgresql")

Enable it per datasource:

flyway:
  datasources:
    default:
      enabled: true

Migrations go in src/main/resources/db/migration with the standard naming:

-- V1__create_accounts.sql
CREATE TABLE accounts (
  id BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Micronaut runs pending migrations at startup, before the server accepts traffic. Flyway holds a lock during migration, so concurrent replicas won't trample each other — one migrates, the rest wait. Keep migrations forward-only in production; if you need to undo something, write a new migration that reverses it rather than editing history.

A production Dockerfile

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY gradlew build.gradle.kts settings.gradle.kts gradle.properties ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon -q || true
COPY src src
RUN ./gradlew build -x test --no-daemon

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && useradd -r micronaut
USER micronaut
EXPOSE 8080
ENV MICRONAUT_ENVIRONMENTS=prod
ENTRYPOINT ["/entrypoint.sh"]

Two details: the wildcard *-all.jar copy picks the shadow JAR regardless of version bumps, and warming the dependency cache before copying src keeps rebuilds fast when only application code changed.

If you use Micronaut's management module, wire the health endpoint into your platform's health checks:

endpoints:
  health:
    enabled: true

GET /health returns readiness including a datasource check — much better than "the port is open" as a liveness signal.

Deploying on PandaStack

The platform side is deliberately boring:

  1. 1Create a managed PostgreSQL instance (14.x or 16.x) from the dashboard.
  2. 2Connect your Git repo as a container app. The Dockerfile is detected and built with rootless BuildKit in an ephemeral Kubernetes job pod — no privileged Docker daemon anywhere in the path.
  3. 3Attach the database. DATABASE_URL is injected into the app automatically, and the entrypoint script translates it into the DATASOURCES_DEFAULT_* variables Micronaut expects.
  4. 4Set MICRONAUT_ENVIRONMENTS=prod (if not baked into the image) and any secrets in the app's environment variables.
  5. 5Push. Builds stream live logs, and every push to the connected branch redeploys. Rollbacks and deployment history are there when a release goes sideways.

One platform behavior worth knowing: on the free tier, idle apps scale to zero and cold-start on the next request. Micronaut's compile-time DI keeps JVM startup short, which makes it one of the better JVM frameworks to pair with scale-to-zero — but if your app must answer instantly at 3 a.m. after hours of silence, that's the signal to move it to a paid tier on stable nodes.

Micronaut also compiles to GraalVM native images (./gradlew nativeCompile with the native plugin), which drops cold starts to milliseconds. Same advice as always: ship the JVM build first, confirm the pipeline works, then optimize.

If you want to see the whole thing run — push a Micronaut repo, watch it build, hit the URL — the free tier at https://pandastack.io is enough to try it.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also