How Services Find Each Other: A Hands-On Guide to Stork and Consul with Quarkus
Run two provider instances, one consumer, and a local Consul agent to learn how discovery, health checks, and load balancing work step by step.
A URL in application.properties works until the process moves. One provider restarts on a different port, a second instance shows up somewhere else, and the consumer still calls http://localhost:8081 like nothing changed.
Kubernetes hides a lot of this behind Service DNS, health checks, and load balancing. Outside Kubernetes, teams usually wire hosts through environment variables and copied host lists, sometimes with a shell script in the middle. The client still has to find a live instance. We can hide that work or make it explicit.
This walkthrough keeps the system small on purpose. We run one Quarkus provider twice, one Quarkus consumer once, and a local Consul agent. The provider registers itself in Consul with distinct instance IDs. The consumer asks SmallRye Stork to discover catalog-service and pick an instance with round robin. Then we stop one instance and watch what changes.
What we build
catalog-servicerunning on ports8081and8082checkout-servicerunning on port8080a local Consul dev agent on port
8500explicit provider registration in Consul with health checks
Stork-based discovery and client-side load balancing in the consumer
We are not here to prove two services can talk. The point is to see how three jobs split:
Discovery finds candidate instances
Selection picks one instance from that list
The client still owns this problem, even when a platform hides the wiring
What you need
You need a normal Quarkus setup and a local container runtime. We are not doing anything exotic, but we are running three local processes plus Consul, so a second or third terminal helps.
JDK 21
Podman or Docker
curlOptional:
jqfor prettier outputAbout ☕️☕️☕️
Start from the repo
If you want to skip project generation, clone the finished code and jump to “Run both provider instances and the consumer”:
git clone https://github.com/myfear/the-main-thread.git
cd the-main-thread/dockyard-discoveryOtherwise, generate the two apps below and build them step by step.
I use a local Consul dev agent here so you can see what the registry returns. Per the official Consul dev mode docs, -dev is for demos and testing, not production.
Start Consul first
Run a local Consul agent:
podman run --name consul -d \
-p 8500:8500 \
-p 8600:8600/udp \
hashicorp/consul \
consul agent -dev -client=0.0.0.0Verify that the HTTP API is reachable:
curl http://localhost:8500/v1/status/leaderYou should get a JSON string with a local address such as:
"127.0.0.1:8300"If that endpoint is not up, stop here. The rest of the walkthrough assumes Consul on localhost:8500.
Create the two Quarkus apps
Generate the provider first:
quarkus create app dev.themainthread:catalog-service \
--extension='rest-jackson,smallrye-health' \
--java=21 \
--no-code Generate the consumer next:
quarkus create app dev.themainthread:checkout-service \
--extension='rest-jackson,rest-client-jackson,smallrye-stork' \
--java=21 \
--no-code Why these extensions:
rest-jacksongives the provider a JSON endpoint and exposes the consumer’s/quoteresourcerest-client-jacksongives the consumer a typed REST clientsmallrye-healthgives the provider a liveness endpoint we can wire into Consul health checkssmallrye-storkintegrates Stork with Quarkus and the REST client
Now add the registry-specific pieces.
In catalog-service/pom.xml, add:
<dependency>
<groupId>io.smallrye.reactive</groupId>
<artifactId>smallrye-mutiny-vertx-consul-client</artifactId>
</dependency>In checkout-service/pom.xml, add:
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-consul</artifactId>
</dependency>Quarkus also has an automatic Stork registration guide, and the docs mark that area as preview. For a single instance, that path may be enough.
Here we register by hand. We run the same provider jar twice on two ports, each with its own Consul service ID, both under the logical name catalog-service. A small registration bean sets the instance ID explicitly. I needed that so we can stop one instance and watch the other stay in the registry.
Build the provider
The provider does one job: return enough information to show which instance answered the request.
Create catalog-service/src/main/java/dev/themainthread/catalog/CatalogInstanceConfig.java:
package dev.themainthread.catalog;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "catalog.instance")
public interface CatalogInstanceConfig {
String id();
String color();
}Create catalog-service/src/main/java/dev/themainthread/catalog/CatalogConsulConfig.java:
package dev.themainthread.catalog;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
@ConfigMapping(prefix = "catalog.consul")
public interface CatalogConsulConfig {
@WithDefault("true")
boolean registrationEnabled();
@WithDefault("localhost")
String host();
@WithDefault("8500")
int port();
@WithDefault("127.0.0.1")
String advertisedAddress();
@WithDefault("host.containers.internal")
String healthCheckHost();
@WithDefault("5s")
String healthCheckInterval();
@WithDefault("20s")
String healthCheckDeregisterAfter();
}Create catalog-service/src/main/java/dev/themainthread/catalog/CatalogResponse.java:
package dev.themainthread.catalog;
import java.math.BigDecimal;
import java.time.Instant;
public record CatalogResponse(
String sku,
BigDecimal price,
String instanceId,
String color,
Instant servedAt) {
}Create catalog-service/src/main/java/dev/themainthread/catalog/CatalogResource.java:
package dev.themainthread.catalog;
import java.math.BigDecimal;
import java.time.Instant;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/catalog")
@Produces(MediaType.APPLICATION_JSON)
public class CatalogResource {
private final CatalogInstanceConfig instance;
public CatalogResource(CatalogInstanceConfig instance) {
this.instance = instance;
}
@GET
@Path("/{sku}")
public CatalogResponse get(@PathParam("sku") String sku) {
return new CatalogResponse(
sku,
new BigDecimal("19.99"),
instance.id(),
instance.color(),
Instant.now());
}
}The endpoint is thin on purpose. The response tells us which provider instance served the request, so when Stork rotates traffic we can see it in the JSON instead of tailing logs.
Create catalog-service/src/main/java/dev/themainthread/catalog/CatalogConsulRegistration.java:
package dev.themainthread.catalog;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import io.vertx.ext.consul.CheckOptions;
import io.vertx.ext.consul.ConsulClientOptions;
import io.vertx.ext.consul.ServiceOptions;
import io.vertx.mutiny.core.Vertx;
import io.vertx.mutiny.ext.consul.ConsulClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class CatalogConsulRegistration {
private static final Logger LOG = Logger.getLogger(CatalogConsulRegistration.class);
private static final String CONSUL_SERVICE_NAME = "catalog-service";
private final CatalogInstanceConfig instance;
private final CatalogConsulConfig consul;
private final Vertx vertx;
private final int port;
private ConsulClient consulClient;
public CatalogConsulRegistration(
CatalogInstanceConfig instance,
CatalogConsulConfig consul,
Vertx vertx,
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8081") int port) {
this.instance = instance;
this.consul = consul;
this.vertx = vertx;
this.port = port;
}
void onStart(@Observes StartupEvent event) {
if (!consul.registrationEnabled()) {
LOG.debugf("Consul registration disabled for %s instance %s", CONSUL_SERVICE_NAME, instance.id());
return;
}
consulClient = ConsulClient.create(vertx, new ConsulClientOptions()
.setHost(consul.host())
.setPort(consul.port()));
String healthCheckUrl = "http://" + consul.healthCheckHost() + ":" + port + "/q/health/live";
CheckOptions check = new CheckOptions()
.setHttp(healthCheckUrl)
.setInterval(consul.healthCheckInterval())
.setDeregisterAfter(consul.healthCheckDeregisterAfter());
ServiceOptions service = new ServiceOptions()
.setName(CONSUL_SERVICE_NAME)
.setId(instance.id())
.setAddress(consul.advertisedAddress())
.setPort(port)
.setCheckOptions(check);
consulClient.registerServiceAndAwait(service);
LOG.infof("Registered %s instance %s at %s:%d", CONSUL_SERVICE_NAME, instance.id(),
consul.advertisedAddress(), port);
}
void onStop(@Observes ShutdownEvent event) {
if (!consul.registrationEnabled() || consulClient == null) {
return;
}
consulClient.deregisterServiceAndAwait(instance.id());
LOG.infof("Deregistered %s instance %s", CONSUL_SERVICE_NAME, instance.id());
}
}Watch the registration shape, not the line count.
Each provider registers as catalog-service, but with its own Consul service ID from INSTANCE_ID. When we stop catalog-2 later, only that ID should leave the registry. catalog-1 should stay.
Now configure the provider in catalog-service/src/main/resources/application.properties:
quarkus.application.name=catalog-service
quarkus.http.port=${HTTP_PORT:8081}
catalog.instance.id=${INSTANCE_ID:catalog-1}
catalog.instance.color=${INSTANCE_COLOR:blue}
catalog.consul.registration-enabled=${CONSUL_REGISTRATION_ENABLED:true}
catalog.consul.host=${CONSUL_HOST:localhost}
catalog.consul.port=${CONSUL_PORT:8500}
catalog.consul.advertised-address=${CONSUL_ADVERTISED_ADDRESS:127.0.0.1}
catalog.consul.health-check-host=${CONSUL_HEALTH_CHECK_HOST:host.containers.internal}
catalog.consul.health-check-interval=5s
catalog.consul.health-check-deregister-after=20s
%test.catalog.instance.id=catalog-test
%test.catalog.instance.color=test-blue
%test.catalog.consul.registration-enabled=falseThe important pieces:
quarkus.application.name=catalog-serviceis the logical service name the consumer asks forHTTP_PORT,INSTANCE_ID, andINSTANCE_COLORlet us run the same app twice with distinct identityCONSUL_HOSTandCONSUL_PORTput the registry location in config, not hardcoded in JavaCONSUL_ADVERTISED_ADDRESSis the address the consumer should call after discoveryCONSUL_HEALTH_CHECK_HOSTis the host the Consul container probes for/q/health/livethe
%testsection turns registration off so the provider test can stay local and boring
The default host.containers.internal value works well with Podman. If you are using Docker Desktop, host.docker.internal is usually the right health-check host instead.
Build the consumer
The consumer is where Stork shows up in code. It does not know whether the provider lives on 8081, 8082, or somewhere else next week. It only knows the logical service name: catalog-service.
Create checkout-service/src/main/java/dev/themainthread/checkout/CatalogQuote.java:
package dev.themainthread.checkout;
import java.math.BigDecimal;
import java.time.Instant;
public record CatalogQuote(
String sku,
BigDecimal price,
String instanceId,
String color,
Instant servedAt) {
}Create checkout-service/src/main/java/dev/themainthread/checkout/CatalogClient.java:
package dev.themainthread.checkout;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/catalog")
@RegisterRestClient(baseUri = "stork://catalog-service")
public interface CatalogClient {
@GET
@Path("/{sku}")
@Produces(MediaType.APPLICATION_JSON)
CatalogQuote get(@PathParam("sku") String sku);
}Create checkout-service/src/main/java/dev/themainthread/checkout/QuoteResource.java:
package dev.themainthread.checkout;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/quote")
@Produces(MediaType.APPLICATION_JSON)
public class QuoteResource {
private final CatalogClient catalogClient;
public QuoteResource(@RestClient CatalogClient catalogClient) {
this.catalogClient = catalogClient;
}
@GET
@Path("/{sku}")
public CatalogQuote quote(@PathParam("sku") String sku) {
return catalogClient.get(sku);
}
}This line wires the services together:
@RegisterRestClient(baseUri = "stork://catalog-service")stork:// replaces a fixed base URL. Stork looks up catalog-service in Consul, runs the configured load balancer, and gives the REST client one concrete host and port.
Now add the consumer configuration in checkout-service/src/main/resources/application.properties:
quarkus.http.port=8080
quarkus.stork.catalog-service.service-discovery.type=consul
quarkus.stork.catalog-service.service-discovery.consul-host=localhost
quarkus.stork.catalog-service.service-discovery.consul-port=8500
quarkus.stork.catalog-service.service-discovery.refresh-period=2S
quarkus.stork.catalog-service.load-balancer.type=round-robinThree jobs, three owners:
Consul returns candidate instances (discovery)
Stork picks one (selection)
the REST client calls whatever address Stork resolved
People often lump all of that under “service discovery.” In this setup you can point at each step.
I set a short refresh-period because I want Stork to pick up registry changes quickly during the demo. The default can leave you calling a dead instance longer than is useful locally.
Prove the wiring with tests first
Before we spin up three processes and a registry, run the tests and check the seams in code.
The provider test stays simple:
cd catalog-service
./mvnw testThat checks the /catalog/{sku} response and verifies the endpoint returns the configured instance metadata.
The consumer test is more interesting:
cd checkout-service
./mvnw testThat test suite starts:
two lightweight HTTP servers acting like
catalog-servicea fake Consul HTTP endpoint that returns those instances with real Consul-style headers
The tests cover three things:
the consumer resolves a provider through Consul-backed Stork discovery
round robin eventually uses both instances
when one instance disappears from the registry, the consumer falls back to the remaining one
I like this split. The provider test checks the response shape. The consumer test checks discovery and round robin without starting real Consul on every ./mvnw test.
Run both provider instances and the consumer
Start the first provider instance:
cd catalog-service
INSTANCE_ID=catalog-1 INSTANCE_COLOR=blue HTTP_PORT=8081 ./mvnw quarkus:devOpen a second terminal and start the same provider again on another port:
cd catalog-service
INSTANCE_ID=catalog-2 INSTANCE_COLOR=green HTTP_PORT=8082 ./mvnw quarkus:devIf you are using Docker Desktop instead of Podman, add CONSUL_HEALTH_CHECK_HOST=host.docker.internal to both commands.
Open a third terminal and start the consumer:
cd checkout-service
./mvnw quarkus:devBefore calling the consumer, ask Consul what it sees:
curl -s 'http://localhost:8500/v1/health/service/catalog-service?passing=true' | jq '.[].Service.Port'If you do not use jq, call the same endpoint and read the raw JSON.
You should see both provider ports:
8081
8082Before you run the loop, make one prediction: if only one provider instance were healthy, would round robin still have anything to do?
Now hit the consumer several times:
for i in {1..6}; do
curl -s http://localhost:8080/quote/sku-1 | jq '{instanceId, color, servedAt}'
doneYou should see the responses alternate between catalog-1 and catalog-2. The timestamps will differ, but the interesting fields look like this:
{
"instanceId": "catalog-1",
"color": "blue"
}
{
"instanceId": "catalog-2",
"color": "green"
}Round robin is working. The consumer never had a host list. It only asked for catalog-service.
When everything is up, you can also run the repo smoke script:
./scripts/smoke.shStop one provider instance
Stop the second provider with Ctrl+C in its terminal.
Call the consumer again:
for i in {1..4}; do
curl -s http://localhost:8080/quote/sku-1 | jq '{instanceId, color}'
doneNow every response should come from catalog-1.
A clean shutdown is the simple case. The registration bean deregisters its Consul ID on exit, so the instance drops out and Stork stops seeing it on the next lookup.
A crash is harder. There is no deregistration call, so Consul relies on the health check. It may take a few polling intervals before the instance leaves the healthy set. Until then, the consumer may still try that address.
Platforms can hide these details, but the behaviors still exist:
something registers the instance
something marks dead instances unhealthy
something on the client picks from the healthy set
Stork makes the third step visible in code.
Limits of this setup
I like this setup for demos, local multi-service development, VM-based deployments, and teams outside Kubernetes. It does not replace everything a platform gives you.
A few things to keep in mind:
The current automatic Stork registration guide is still marked preview. I would treat that as a real signal if you plan to standardize on it broadly.
This demo uses
127.0.0.1as the advertised service address because everything is on one machine. That is correct here and wrong in many real deployments.Round robin is ok because it is easy to see. It is not always the best production balancing strategy.
Consul dev mode is a toy. Production Consul means agents, ACLs, and backup plans.
Consul also backs externalized configuration through the Quarkiverse Consul config extension. That solves a different problem from instance discovery.
Those limits are fine for a tutorial. Just do not confuse this with production Consul operations.
Where this fits when you do have Kubernetes
Inside Kubernetes, a normal Service plus cluster DNS is enough for many apps. I would not add Consul on top just because it feels more “cloud native.”
Outside Kubernetes, that machinery is gone. You still need registration, health checks, and client-side selection. You just choose how explicit you want each piece to be. Stork plus Consul is one reasonable option.
Stork is also not tied to Consul. Quarkus has a separate Stork with Kubernetes guide, so the same client-side model can follow you when the registry changes.
Service discovery is client work. You need a registry, a health signal, and a selection policy. Kubernetes can run those for you. Outside the cluster, you pick the pieces.
Close the loop
We started with a hardcoded URL and ended with something that survives moves:
the provider registers itself
Consul tracks which instances pass health checks
the consumer asks for
catalog-serviceby nameStork picks a concrete instance on each call
Hosts and ports can change. The client still needs a sane way to find a live instance.



