Back to Blog
Tutorial6 min read2026-07-12

How to Deploy a Quarkus App with PostgreSQL on PandaStack

Build the fast-jar, convert DATABASE_URL into JDBC config, run Flyway on boot, and ship a Quarkus app to production via Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Quarkus is one of the nicest ways to run Java in containers today: fast startup, low memory, and a dev mode that does a suspicious amount of work for you. That last part is also where deployments go wrong. Dev mode spins up databases automatically, binds to localhost, and hot-reloads everything — none of which exists in production. Here's the full path from ./mvnw quarkus:dev to a running production app with a managed PostgreSQL database, with the gotchas called out.

Building Quarkus for production

The default Quarkus package type is fast-jar. A standard Maven build:

./mvnw package

produces target/quarkus-app/, and the important thing to internalize is that this is a *directory layout*, not a single jar:

target/quarkus-app/
├── app/                 # your code
├── lib/                 # dependencies, split for layer caching
├── quarkus/             # quarkus internals
├── quarkus-app-dependencies.txt
└── quarkus-run.jar      # tiny launcher

You run it with:

java -jar target/quarkus-app/quarkus-run.jar

The classic mistake is copying only quarkus-run.jar into a Docker image. It's a launcher a few kilobytes in size — it needs the sibling directories next to it. Copy the whole quarkus-app/ directory, or your container will die instantly with a ClassNotFoundException.

If you'd rather have one file, you can switch to an uber-jar in application.properties, but the fast-jar layout is the default for a reason: the split lib/ directory means Docker layer caching only rebuilds the layer with your app code, not the one with all your dependencies.

Port and host: the dev-mode trap

Quarkus listens on 8080 by default. Two details matter in production:

  • In dev and test mode, Quarkus binds to localhost. In prod mode it binds to 0.0.0.0, which is what a container platform needs. So this usually just works — but if you've overridden quarkus.http.host during local debugging, remove it before deploying.
  • If your platform injects a PORT environment variable, map it explicitly:
# application.properties
quarkus.http.port=${PORT:8080}

The ${PORT:8080} syntax is MicroProfile Config: use PORT if set, fall back to 8080 otherwise. It keeps local runs working unchanged.

Configuring PostgreSQL — and the Dev Services gotcha

Add the JDBC driver extension if you haven't:

./mvnw quarkus:add-extension -Dextensions='jdbc-postgresql'

Here's the trap: in dev mode, if you have the Postgres extension but no datasource configuration, Quarkus Dev Services silently starts a throwaway PostgreSQL container for you via Testcontainers. Your app works beautifully on your laptop with zero config — and then fails to start in production, because there's no Docker daemon conjuring databases there. If you've been coasting on Dev Services, production is the first time you've ever actually configured a datasource.

The prod configuration, using the %prod. profile prefix so dev mode keeps using Dev Services:

# application.properties
quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=${QUARKUS_DATASOURCE_JDBC_URL}
%prod.quarkus.datasource.username=${QUARKUS_DATASOURCE_USERNAME}
%prod.quarkus.datasource.password=${QUARKUS_DATASOURCE_PASSWORD}

Strictly speaking those three env vars are already mapped automatically by MicroProfile Config (QUARKUS_DATASOURCE_JDBC_URLquarkus.datasource.jdbc.url), but being explicit makes the contract visible to whoever reads the file next.

Turning DATABASE_URL into JDBC config

Managed platforms — PandaStack included — hand your app a single DATABASE_URL in the form:

postgres://user:password@host:5432/dbname

JDBC won't accept that. It wants jdbc:postgresql://host:5432/dbname plus separate username and password. The cleanest fix I've found is a five-line entrypoint script that splits the URL before the JVM starts:

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

One caveat: if the generated password contains URL-encoded characters (%40 and friends), decode it first or regenerate credentials without special characters. Most managed platforms generate alphanumeric passwords precisely to avoid this.

Flyway migrations on boot

Quarkus's Flyway integration is the pragmatic choice for schema management:

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-flyway</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-postgresql</artifactId>
</dependency>

The second dependency catches people out: since Flyway 10, database support moved into separate modules, so quarkus-flyway alone gives you a confusing "unsupported database" error against Postgres.

Migrations live in src/main/resources/db/migration with Flyway's naming scheme:

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

Then enable migration at startup:

quarkus.flyway.migrate-at-start=true

Flyway takes a database-level lock while migrating, so two replicas starting simultaneously won't corrupt each other — one migrates, the other waits. For a small app, migrate-at-start is fine. Once migrations start taking minutes, move them to a separate release step so app startup stays fast.

A production Dockerfile

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/quarkus-app/ ./
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && useradd -r quarkus
USER quarkus
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

Note the COPY --from=build /app/target/quarkus-app/ ./ — the whole directory, per the fast-jar discussion above. The dependency:go-offline step before copying src means dependency downloads are cached across builds unless pom.xml changes.

Deploying on PandaStack

With the Dockerfile in place, the deployment itself is short:

  1. 1Create a managed PostgreSQL instance (14.x or 16.x are available) from the dashboard.
  2. 2Connect your Git repo as a container app. PandaStack detects the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job — no host Docker socket involved.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically, which is exactly what the entrypoint script above expects — no copying credentials between tabs.
  4. 4Push to your branch. The build runs, you can watch the live build logs stream, and the app goes live. Every subsequent git push redeploys.

Extra environment variables (API keys, QUARKUS_LOG_LEVEL, feature flags) go in the app's environment settings; Quarkus picks them up through MicroProfile Config with no code changes.

If something fails on first boot, it's almost always one of three things, in this order: the datasource wasn't configured (Dev Services masking it locally), the container copied only quarkus-run.jar, or Flyway is missing the Postgres module. Check the app logs — the Quarkus error messages for all three are explicit once you know to look.

A note on native images

Quarkus's headline feature is GraalVM native compilation — startup in tens of milliseconds and a fraction of JVM memory:

./mvnw package -Dnative -Dquarkus.native.container-build=true

The container-build flag runs GraalVM inside a container, so you don't need it installed locally. The trade-offs are real: native builds take several minutes and eat RAM at build time, and some libraries need reflection configuration. My honest advice: ship the JVM build first, get the pipeline working end to end, and move to native only if memory footprint actually becomes your constraint. On a platform where free-tier apps scale to zero when idle, the fast cold start of a native image pairs nicely — but it's an optimization, not a prerequisite.

If you want to try the whole flow — repo to running Quarkus app with a wired-in Postgres — you can do it on the free tier at https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also