Eclipse Vert.x is a toolkit, not a framework, which is part of why it's so good at high-concurrency workloads. It runs on a small number of event-loop threads and never blocks them, letting a single instance handle tens of thousands of connections. Deploying it well means respecting that model: sizing threads correctly, keeping blocking work off the event loop, and configuring health checks that reflect actual readiness.
The app structure
A typical Vert.x app deploys one or more verticles. Here's a minimal HTTP server verticle that reads its port from the environment:
public class MainVerticle extends AbstractVerticle {
@Override
public void start(Promise<Void> startPromise) {
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
Router router = Router.router(vertx);
router.get("/health").handler(ctx -> ctx.response().end("OK"));
router.get("/api/items").handler(this::listItems);
vertx.createHttpServer()
.requestHandler(router)
.listen(port, "0.0.0.0")
.onSuccess(s -> startPromise.complete())
.onFailure(startPromise::fail);
}
}Binding to 0.0.0.0 is non-negotiable in a container, otherwise the server is unreachable from outside the pod.
Reactive database access
Vert.x has its own non-blocking Postgres client. Use it instead of JDBC so you don't block event-loop threads waiting on the database:
PgConnectOptions options = PgConnectOptions.fromUri(System.getenv("DATABASE_URL"));
Pool pool = PgBuilder.pool()
.connectingTo(options)
.with(new PoolOptions().setMaxSize(8))
.using(vertx)
.build();The fromUri helper parses a standard postgresql://user:pass@host:5432/db URL, which is exactly the format managed databases provide. Keep the pool size modest. Vert.x multiplexes, so you rarely need a large pool; eight connections per instance is plenty for most APIs.
Building a fat JAR
Vert.x apps deploy as a single executable JAR. With Maven Shade or the Gradle Shadow plugin you produce a fat JAR that includes everything:
./gradlew shadowJarThen a clean two-stage Dockerfile:
FROM gradle:8.7-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle shadowJar --no-daemon
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", \
"-Dvertx.options.eventLoopPoolSize=2", "-jar", "app.jar"]Sizing the event loop for containers
By default Vert.x sets the event-loop pool to 2 * available processors. In a container with a CPU limit, the JVM may misread the number of available cores. Set eventLoopPoolSize explicitly to match your container's CPU allocation. On a 1-CPU tier, two event-loop threads is a reasonable choice; over-provisioning threads just adds context-switching overhead.
For any blocking work (legacy JDBC calls, heavy CPU computation), use executeBlocking or a dedicated worker verticle so you never stall the event loop. A blocked event loop is the single most common cause of mysterious latency spikes in Vert.x deployments.
Health checks and backpressure
Vert.x ships a health-check handler in vertx-health-check. Wire a readiness check that actually pings the database pool, so the orchestrator doesn't route traffic to an instance whose database is unreachable:
HealthChecks hc = HealthChecks.create(vertx);
hc.register("db", promise ->
pool.query("SELECT 1").execute()
.onSuccess(r -> promise.complete(Status.OK()))
.onFailure(t -> promise.complete(Status.KO())));
router.get("/ready").handler(HealthCheckHandler.createWithHealthChecks(hc));For streaming endpoints, lean on Vert.x's built-in backpressure: ReadStream.pause()/resume() and the Pipe API prevent a fast producer from overwhelming a slow consumer. This is one of the framework's strongest features and a reason to use it over a thread-per-request stack.
Deploying on PandaStack
With a Dockerfile committed, the deploy is a git connection:
- 1Connect your GitHub repo as a container app in the dashboard.
- 2PandaStack builds with rootless BuildKit in an ephemeral Job pod and pushes to Google Artifact Registry, then deploys via Helm.
- 3Provision a managed PostgreSQL instance;
DATABASE_URLis injected automatically andPgConnectOptions.fromUripicks it up. - 4Watch live build and app logs to confirm both the HTTP server bound and the readiness check turned green.
| Concern | Setting |
|---|---|
| Bind address | 0.0.0.0 (required) |
| Port | read PORT env |
| Event-loop threads | match CPU tier |
| DB pool size | 4-8 per instance |
| Readiness | DB ping, not just process up |
Free-tier apps scale to zero on spot nodes inside a gVisor sandbox, so a low-traffic Vert.x service costs nothing while idle and cold-starts on the next request. For a reactive app that's typically handling bursty traffic, scale-to-zero is a good economic fit.
Verifying
curl https://your-app.pandastack.app/ready
curl https://your-app.pandastack.app/api/itemsAttach a custom domain for automatic SSL, and use the server-side metrics view for latency and throughput without adding any instrumentation to your code.
References
- Vert.x core documentation: https://vertx.io/docs/vertx-core/java/
- Vert.x reactive Postgres client: https://vertx.io/docs/vertx-pg-client/java/
- Vert.x health checks: https://vertx.io/docs/vertx-health-check/java/
- Gradle Shadow plugin: https://gradleup.com/shadow/
- JVM container CPU/memory ergonomics: https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
Vert.x rewards a deployment that respects its event-loop model. Get the thread sizing and non-blocking database access right and a single small instance goes a long way. Try the full flow, build to live URL with an auto-wired database, on PandaStack's free tier at https://dashboard.pandastack.io