Spring Boot

Service Registry

In a system of many services whose addresses change as instances start, stop and scale, a service registry is the live directory that maps a logical name such as inventory-service to the instances currently able to serve it. This guide covers when you need one, how to run Eureka as a secured, highly available server, how clients register and what the lease and heartbeat settings really do, how to make health checks meaningful, load balancing and zone preference through Spring Cloud LoadBalancer, self-preservation mode, graceful shutdown during rolling deployments, Consul as an alternative, and the operational problems that show up in practice, mostly stale instances and slow propagation.

Do You Need a Service Registry?

A registry solves one problem: letting a caller find healthy instances of a service whose network addresses are not fixed. Instances register themselves when they start, keep a lease alive with periodic heartbeats, and are removed when the lease expires or they deregister on shutdown. Clients fetch the list, cache it locally, and pick an instance for each call, so a lookup is not on the hot path of every request. You do not always need to run one. On Kubernetes, the platform already provides discovery: a Service gives a stable DNS name and load-balances across ready pods, so calling http://inventory-service from another pod works without any Spring Cloud component. If you want Spring's DiscoveryClient abstraction on top of that, spring-cloud-kubernetes provides it. A service mesh also handles discovery, retries and load balancing outside the application. A registry such as Eureka or Consul is the better fit when you deploy on VMs, on plain Docker hosts, or across environments that have no built-in discovery, or when you want client-side load balancing with application-level metadata like zone or version. Two properties of registries are easy to forget and cause most production surprises. They are eventually consistent: a new or removed instance is not visible everywhere at once, because the server caches responses, clients cache the registry, and the load balancer caches the instance list, so propagation takes tens of seconds to a couple of minutes. And they only know what instances claim, so an instance that is hung but still sending heartbeats stays in the list unless health checks are wired in properly.
Shell
# Where the delays come from (Eureka defaults, worst case adds up)
#
#   instance registers / changes status
#     -> Eureka server response cache refreshes     every 30s   (response-cache-update-interval-ms)
#     -> client fetches the registry delta          every 30s   (registry-fetch-interval-seconds)
#     -> Spring Cloud LoadBalancer instance cache   up to 35s   (spring.cloud.loadbalancer.cache.ttl)
#
#   instance dies without deregistering
#     -> lease expires after                        90s default (lease-expiration-duration-in-seconds)
#     -> eviction task runs                         every 60s   (eviction-interval-timer-in-ms)
#     -> then the caches above must refresh
#
# Result: callers can keep sending traffic to a dead instance for several minutes.
# Plan for it with timeouts, retries on idempotent calls and circuit breakers.

# Choosing
#   Kubernetes                       -> Kubernetes Services (DNS) or spring-cloud-kubernetes
#   VMs / Docker hosts / hybrid      -> Eureka or Consul
#   Need KV config + health + ACLs   -> Consul
#   Spring-only estate, simple setup -> Eureka

Dependencies and Release Train

Spring Cloud modules are versioned together as a release train, and each train supports specific Spring Boot versions. Do not pick individual Spring Cloud artifact versions; import the spring-cloud-dependencies BOM for the train that matches your Boot version (check the compatibility table on the Spring Cloud site) and leave the starters versionless. A mismatch is a common reason a Eureka server fails to start with classpath errors. The server needs the Eureka server starter. Each client needs the client starter, plus Actuator if you want meaningful health reporting, and the Spring Cloud LoadBalancer starter if you call other services by name.
XML
<!-- pom.xml: import the release train matching your Spring Boot version -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>   <!-- e.g. 2024.0.x for Boot 3.4 -->
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<!-- Eureka SERVER application -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- Every CLIENT service -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

Running a Secured Eureka Server

The server is an ordinary Spring Boot application with @EnableEurekaServer. The registry and its dashboard should never be open: anyone who can reach the registration API can add fake instances and redirect traffic. Put HTTP Basic security in front of it, and note an important detail that trips up almost everyone. Spring Security enables CSRF protection by default, and Eureka clients register with POST requests that carry no CSRF token, so every registration fails with 403 unless you exclude the /eureka/** paths from CSRF checks. For a standalone server, which is fine for development, tell it not to register with itself or fetch a registry from itself. That setting is wrong for a clustered setup, covered in the next section. The credentials come from the environment rather than from the file.
Java
@SpringBootApplication
@EnableEurekaServer
public class ServiceRegistryApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceRegistryApplication.class, args);
    }
}

@Configuration
@EnableWebSecurity
class EurekaSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            // Eureka clients POST/PUT/DELETE without a CSRF token. Without this
            // line every registration and heartbeat fails with 403.
            .csrf(csrf -> csrf.ignoringRequestMatchers("/eureka/**"));
        return http.build();
    }
}

// Without that CSRF exclusion a client logs something like:
//   Cannot execute request on any known server  /  registration status: 403

Eureka Server Configuration (Standalone)

A single standalone server is perfectly fine on a developer machine. The two client flags are switched off so that it does not try to register with itself, and the dashboard is available at the root URL, protected by the basic credentials. The wait-time-in-ms-when-sync-empty property is not an eviction grace period. It controls how long a starting server waits for its peers to supply registry data before it begins serving it. A standalone server has no peers, so zero avoids a pointless five-minute wait on startup, which makes it a useful development setting and one to review for production.
yaml
# application.yml: standalone server (development)
server:
  port: 8761

spring:
  application:
    name: service-registry
  security:
    user:
      name: ${EUREKA_USER:eureka}
      password: ${EUREKA_PASSWORD}

eureka:
  instance:
    hostname: localhost
  client:
    register-with-eureka: false     # do not register with itself
    fetch-registry: false           # nothing to fetch from itself
    service-url:
      defaultZone: http://localhost:8761/eureka/
  server:
    # Time a starting server waits for peers to give it registry data.
    # No peers in standalone mode, so skip the wait. Keep the default in a cluster.
    wait-time-in-ms-when-sync-empty: 0

# Dashboard: http://localhost:8761  (basic auth)
# Registry API: GET http://localhost:8761/eureka/apps   (add Accept: application/json)

High Availability with Peer Replication

In production, run at least two Eureka servers that replicate to each other. In this peer-aware mode each server registers with and fetches from the other, so register-with-eureka and fetch-registry must be true, the opposite of the standalone settings. Give every server the same spring.application.name, a distinct hostname, and a defaultZone that lists its peers. Clients list all servers, so that they can fail over. Eureka favours availability over consistency. Servers keep serving their last known registry even when they cannot reach each other, which is the right choice for discovery: a slightly stale answer beats no answer. Do not put credentials in URLs in files you commit; use placeholders resolved from the environment or a secret store. Clients should list the servers in the same availability zone first, since Eureka prefers a server in the client's own zone.
yaml
# application.yml: shared by both peers
spring:
  application:
    name: service-registry
  security:
    user:
      name: ${EUREKA_USER:eureka}
      password: ${EUREKA_PASSWORD}

eureka:
  client:
    register-with-eureka: true      # peers register with each other
    fetch-registry: true

---
spring:
  config:
    activate:
      on-profile: peer1
server:
  port: 8761
eureka:
  instance:
    hostname: eureka-peer1
  client:
    service-url:
      defaultZone: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-peer2:8761/eureka/

---
spring:
  config:
    activate:
      on-profile: peer2
server:
  port: 8761
eureka:
  instance:
    hostname: eureka-peer2
  client:
    service-url:
      defaultZone: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-peer1:8761/eureka/

# Clients list every server so they can fail over:
#   eureka.client.service-url.defaultZone:
#     http://user:pass@eureka-peer1:8761/eureka/,http://user:pass@eureka-peer2:8761/eureka/
#
# Start with: --spring.profiles.active=peer1  /  peer2

Eureka Client Registration

With the client starter on the classpath, a Spring Boot 3 application registers automatically; @EnableDiscoveryClient is optional. The name in spring.application.name becomes the service id that other services use to call it. Eureka upper-cases it in the dashboard, but lookups are case-insensitive, and lowercase with hyphens is the convention. Two details in the configuration are often wrong. prefer-ip-address belongs under eureka.instance, not under eureka.client; placed under client it is silently ignored. And YAML folded scalars (the > style) turn newlines into spaces, so splitting a URL or an instance id over several lines inserts spaces into it and breaks it. Keep each such value on one line. Instance ids must be unique per running instance. The default is built from hostname, application name and port, which collides when several instances share a host and use a random port (server.port=0). In that case include a random value. The lease settings control how quickly a dead instance disappears. Heartbeats default to every 30 seconds with expiry after 90, so a crashed instance can linger for a minute and a half plus eviction and cache delays. Shortening them speeds up detection but increases traffic and the risk of false evictions when a busy instance is slow to send a heartbeat. Values like 10 and 30 are a reasonable middle ground, and the expiry should stay at least three times the interval.
yaml
# application.yml: client service
spring:
  application:
    name: order-service              # service id, lowercase

eureka:
  client:
    service-url:
      # one line: folded (>) YAML would insert spaces into the URL
      defaultZone: http://${EUREKA_USER:eureka}:${EUREKA_PASSWORD}@eureka-server:8761/eureka/
    registry-fetch-interval-seconds: 30     # local registry cache refresh
  instance:
    prefer-ip-address: true                 # under eureka.instance, NOT eureka.client
    # unique per instance, also with server.port=0
    instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${random.value}}
    lease-renewal-interval-in-seconds: 10   # heartbeat (default 30)
    lease-expiration-duration-in-seconds: 30  # evict after this long without one (default 90)
    status-page-url-path: /actuator/info
    health-check-url-path: /actuator/health

management:
  endpoints:
    web:
      exposure:
        include: health,info

// Optional in Spring Boot 3, since discovery is auto-configured:
//   @EnableDiscoveryClient
//
// Verify: GET http://eureka-server:8761/eureka/apps/ORDER-SERVICE

Health Checks: What Eureka Actually Uses

Out of the box, Eureka knows nothing about your application's health. An instance is UP as long as it keeps sending heartbeats, and a heartbeat comes from a background thread that has no idea whether your database connection pool is exhausted. The health-check-url-path setting only changes the URL displayed on the dashboard; it does not make Eureka call it. To have the instance report its real state, set eureka.client.healthcheck.enabled to true. The client then propagates Actuator's aggregate health to Eureka, and an instance whose health is DOWN is shown as DOWN and no longer chosen by clients once the change propagates. Think carefully about what feeds that health status. If a shared dependency such as the database is part of the aggregate and it goes down, every instance reports DOWN simultaneously, the registry empties, and callers have nowhere to send traffic. The outage becomes total even for requests that did not need the database. A safer approach is to publish only the application's own readiness: whether this instance can accept traffic, with checks for the resources it cannot work without, and to keep external dependencies out of the group that drives registration, relying on circuit breakers for those. Spring Boot already ships a DataSource health indicator, so a hand-written one that opens a connection is rarely needed. If you do write one, use the result of isValid rather than ignoring it.
yaml
# application.yml
eureka:
  client:
    healthcheck:
      enabled: true     # propagate Actuator health to Eureka; without it, heartbeat = UP

management:
  endpoint:
    health:
      probes:
        enabled: true             # adds /actuator/health/liveness and /readiness
      show-details: when-authorized
  health:
    # Keep the registration-driving group narrow: only what this instance cannot live without.
    readinessstate:
      enabled: true
    db:
      enabled: true               # still visible under /actuator/health, see group below
  endpoint.health.group:
    readiness:
      include: readinessState     # add "db" only if the instance truly cannot serve without it

# Eureka statuses
#   UP              taking traffic
#   DOWN            health check failed (needs healthcheck.enabled=true)
#   STARTING        still starting
#   OUT_OF_SERVICE  deliberately taken out of rotation (see graceful shutdown)

// Custom indicator, if you really need one: honour the result of isValid
@Component
class ExternalQuotaHealthIndicator implements HealthIndicator {

    private final DataSource dataSource;

    ExternalQuotaHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Health health() {
        try (Connection c = dataSource.getConnection()) {
            return c.isValid(2)
                ? Health.up().build()
                : Health.down().withDetail("reason", "connection not valid").build();
        } catch (SQLException ex) {
            return Health.down(ex).build();
        }
    }
}

Metadata, Zones and Weighted Load Balancing

Instances can publish arbitrary key-value metadata, such as the availability zone, the version or a weight, and clients can use it for routing. The load balancing itself is done by Spring Cloud LoadBalancer, which builds its instance list through a ServiceInstanceListSupplier. You do not need to write one by hand for common needs: the builder has ready-made decorators for zone preference, weighted selection and caching. Zone preference keeps traffic inside the caller's availability zone when instances there exist, falling back to all instances if none do, which cuts latency and cross-zone transfer costs. Set the caller's own zone with spring.cloud.loadbalancer.zone and give every instance a zone entry in its metadata. Weighted routing reads a weight entry from metadata, handy for sending less traffic to a smaller instance or a canary. Place a LoadBalancer configuration class where component scanning does not pick it up, and attach it with @LoadBalancerClient (for one service) or @LoadBalancerClients (as the default). A @Configuration class that is scanned would apply to every client implicitly and cause confusing behaviour. Eureka has its own notion of zones for server selection (eureka.client.availability-zones and prefer-same-zone-eureka), which is separate from load balancer zone preference. Use both consistently if you run multi-zone.
yaml
# application.yml of the CALLER (e.g. order-service in zone a)
spring:
  cloud:
    loadbalancer:
      zone: us-east-1a                  # this caller's zone, used by withZonePreference()
      cache:
        ttl: 10s                        # instance list cache (default 35s); shorter = fresher

# application.yml of EVERY service instance: what it publishes
eureka:
  instance:
    metadata-map:
      zone: us-east-1a
      version: "2.1.0"
      weight: "100"                     # relative weight used by withWeighted()

// Not annotated with @Configuration: it must not be component-scanned globally.
class InventoryLoadBalancerConfig {

    @Bean
    ServiceInstanceListSupplier supplier(ConfigurableApplicationContext context) {
        return ServiceInstanceListSupplier.builder()
            .withDiscoveryClient()
            .withZonePreference()       // prefer same-zone instances
            .withCaching()
            .build(context);
    }
}

@SpringBootApplication
@LoadBalancerClient(name = "inventory-service",
                    configuration = InventoryLoadBalancerConfig.class)
public class OrderServiceApplication { ... }

// For all services instead of one:
//   @LoadBalancerClients(defaultConfiguration = InventoryLoadBalancerConfig.class)
//
// Weighted instead of zone-based:
//   ServiceInstanceListSupplier.builder().withDiscoveryClient().withWeighted().withCaching().build(context)

Calling Services by Name

Once services are registered, callers should use logical names. A RestClient.Builder or WebClient.Builder annotated with @LoadBalanced is intercepted by Spring Cloud LoadBalancer, which resolves the host in the URL (inventory-service) to one of the instances the registry reports and rotates between them. The load-balanced RestClient builder requires a recent Spring Cloud (the 2023.0 release train, Spring Cloud Commons 4.1, and later); older projects use a @LoadBalanced RestTemplate in the same way. Use DiscoveryClient only for inspection, such as listing services, reading metadata or building an admin page. Taking the first instance from it, as is sometimes shown, is not load balancing: every caller picks the same one, and it ignores zone preference, caching and health. For actual calls let the load balancer choose. Treat every call as something that can hit a stale entry. Set connect and read timeouts, retry only idempotent requests, and put a circuit breaker such as Resilience4j around remote calls, because a registry cannot guarantee that the instance it returned is alive right now.
Java
@Configuration
class HttpClientConfig {

    @Bean
    @LoadBalanced                              // resolves "inventory-service" via the registry
    RestClient.Builder loadBalancedRestClientBuilder() {
        return RestClient.builder();
    }

    @Bean
    RestClient inventoryRestClient(RestClient.Builder builder) {
        return builder
            .baseUrl("http://inventory-service")       // service id, not host:port
            .requestFactory(timeouts())
            .build();
    }

    private ClientHttpRequestFactory timeouts() {
        SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
        f.setConnectTimeout(Duration.ofSeconds(2));
        f.setReadTimeout(Duration.ofSeconds(5));
        return f;
    }
}

@Service
class InventoryGateway {

    private final RestClient inventory;

    InventoryGateway(@Qualifier("inventoryRestClient") RestClient inventory) {
        this.inventory = inventory;
    }

    @CircuitBreaker(name = "inventory")                 // Resilience4j
    StockResponse checkStock(Long productId) {
        return inventory.get()
            .uri("/api/v1/stock/{id}", productId)
            .retrieve()
            .body(StockResponse.class);
    }
}

// Reactive equivalent
@Bean
@LoadBalanced
WebClient.Builder loadBalancedWebClientBuilder() {
    return WebClient.builder();
}

// DiscoveryClient: inspection only
@Service
class RegistryInspector {

    private final DiscoveryClient discovery;

    RegistryInspector(DiscoveryClient discovery) {
        this.discovery = discovery;
    }

    List<String> services() {
        return discovery.getServices();
    }

    List<ServiceInstance> instances(String serviceId) {
        return discovery.getInstances(serviceId);   // host, port, metadata
    }

    // Do NOT do this for real calls: no balancing, no zone preference, no health awareness
    //   discovery.getInstances(id).get(0).getUri()
}

Self-Preservation Mode

Self-preservation is Eureka's protection against mass eviction during a network problem. The server computes how many heartbeats it should receive per minute from the number of registered instances. If the actual number falls below a threshold, by default 85% of that expectation, the server concludes the problem is probably on its side or in the network rather than a sudden death of most services, and it stops evicting instances. You will see a red banner on the dashboard saying that renewals are lower than the threshold and that instances may not be safe to trust. Note the direction. Eviction stops when renewals fall below the threshold, not when they stay above it. The consequence is that, during self-preservation, genuinely dead instances stay in the registry until it recovers, so callers must tolerate failed calls. This is also why the mode is more noticeable in small environments: with only a couple of instances, one or two stopping can push the ratio below the threshold, and the registry then holds onto the dead ones. Keep the protection on in production. In local development, where you start and stop services all the time, turning it off lets dead instances disappear quickly. Set that per profile rather than twice in the same file, since a duplicated YAML key keeps only one of the values.
yaml
# application.yml: production defaults
eureka:
  server:
    enable-self-preservation: true
    renewal-percent-threshold: 0.85        # protect when renewals drop BELOW 85% of expected
    eviction-interval-timer-in-ms: 60000   # how often expired leases are removed (default)

---
spring:
  config:
    activate:
      on-profile: dev
eureka:
  server:
    enable-self-preservation: false        # dev only: dead instances vanish quickly
    eviction-interval-timer-in-ms: 5000

# Dashboard banner when it triggers:
#   "EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP WHEN THEY'RE NOT.
#    RENEWALS ARE LESSER THAN THRESHOLD AND HENCE THE INSTANCES ARE NOT BEING EXPIRED
#    JUST TO BE SAFE."
#
# Practical effect: stale entries persist. Callers need timeouts, retries and circuit breakers.

Graceful Shutdown and Rolling Deployments

When an instance shuts down it deregisters, but callers keep their cached copies of the registry for a while, so requests can still arrive at an instance that is stopping. Two measures keep deployments quiet. Enable graceful shutdown so that the application finishes in-flight requests before the process ends. And take the instance out of rotation before stopping it, so callers have time to drop it from their caches. Actuator's serviceregistry endpoint lets you set the instance's status to OUT_OF_SERVICE for this purpose, which orchestrators can call from a pre-stop hook, followed by a pause roughly as long as the cache delays described earlier. Even so, no setup removes the window entirely, so client-side retries on idempotent requests remain the final safety net.
yaml
# application.yml
server:
  shutdown: graceful                       # finish in-flight requests

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

management:
  endpoints:
    web:
      exposure:
        include: health,info,serviceregistry

# Pre-stop sequence in a deployment script or container hook:
#
#   1. Mark the instance as out of rotation
#        curl -X POST http://localhost:8080/actuator/serviceregistry \
#             -H "Content-Type: application/json" \
#             -d '{"status":"OUT_OF_SERVICE"}'
#
#   2. Wait for callers to refresh their caches (roughly fetch interval + LB cache TTL)
#        sleep 40
#
#   3. Send SIGTERM; Spring finishes in-flight work, deregisters and exits.

# Kubernetes sketch
#   lifecycle:
#     preStop:
#       exec:
#         command: ["sh", "-c", "curl -s -X POST localhost:8080/actuator/serviceregistry -H 'Content-Type: application/json' -d '{\"status\":\"OUT_OF_SERVICE\"}'; sleep 40"]
#   terminationGracePeriodSeconds: 90

Consul as an Alternative Registry

HashiCorp Consul combines service discovery, active health checking and a key-value store. Its health model differs from Eureka's in a helpful way: the Consul agent actively calls your health endpoint at an interval and marks the service critical after failures, and it can deregister services that stay critical for a set time, so you do not depend on the application to report its own state. Spring Cloud Consul plugs into the same DiscoveryClient and @LoadBalanced abstractions, so calling code usually does not change when you switch registries, only the dependency and configuration. Configuration import deserves attention. On Spring Boot 2.4 and later, external configuration sources are loaded with spring.config.import rather than the old bootstrap file, so enabling Consul config needs that property. Without it the Consul key-value data is simply never read. As with Eureka, keep multi-line values out of YAML folded scalars, which insert spaces. Consul tags written as key=value are exposed as instance metadata, which the zone and weight decorators can read just as they do with Eureka metadata. Protect a real cluster with ACL tokens and TLS; the development container below is open by design.
yaml
<!-- pom.xml: replace the Eureka client -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>

# application.yml
spring:
  application:
    name: order-service
  config:
    import: "optional:consul:"            # required on Boot 2.4+ to read Consul KV config
  cloud:
    consul:
      host: ${CONSUL_HOST:localhost}
      port: ${CONSUL_PORT:8500}
      token: ${CONSUL_TOKEN:}              # ACL token on secured clusters
      discovery:
        prefer-ip-address: true
        instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${server.port}
        health-check-path: /actuator/health
        health-check-interval: 10s
        health-check-timeout: 5s
        health-check-critical-timeout: 30s   # deregister if critical this long
        tags:
          - version=${app.version:1.0.0}
          - zone=${AVAILABILITY_ZONE:default}
      config:
        prefix: config
        default-context: application
        format: YAML
        data-key: data
        # Reads KV entries (YAML) at:
        #   config/application/data       shared by all services
        #   config/order-service/data     this service only

# Local development: a single dev agent
#   docker run -d --name consul -p 8500:8500 hashicorp/consul agent -dev -client=0.0.0.0
#   UI: http://localhost:8500

Local Development and Tests

Registries get in the way of tests and of running a single service on a laptop. A service that cannot find Eureka retries and logs errors, which is noisy but harmless; for tests you want it gone completely. Disable discovery in the test profile so that no registration or fetch is attempted, and call collaborators through a plain URL or a stub server instead of a load-balanced name. For multi-service local work, a docker-compose file with the registry and a few services is more reliable than starting everything by hand, and the dev profile with self-preservation off keeps the dashboard honest.
yaml
# src/test/resources/application-test.yml
eureka:
  client:
    enabled: false                  # no registration, no registry fetch
spring:
  cloud:
    discovery:
      enabled: false
    loadbalancer:
      enabled: false

# Consul equivalent
#   spring.cloud.consul.enabled: false
#   spring.cloud.consul.discovery.enabled: false

// Or per test class
@SpringBootTest(properties = {
    "eureka.client.enabled=false",
    "spring.cloud.discovery.enabled=false"
})
class OrderServiceTest { ... }

// For calls to other services in tests, point the client at a stub (WireMock / MockWebServer)
// and use a plain base URL instead of the service id.

# docker-compose.yml (development)
# services:
#   registry:
#     build: ./service-registry
#     environment: { EUREKA_USER: eureka, EUREKA_PASSWORD: dev, SPRING_PROFILES_ACTIVE: dev }
#     ports: ["8761:8761"]
#   order-service:
#     build: ./order-service
#     environment:
#       EUREKA_USER: eureka
#       EUREKA_PASSWORD: dev
#       EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka:dev@registry:8761/eureka/

Troubleshooting

Most registry problems fall into a few groups: registration never happens, instances register with an unreachable address, and traffic keeps going to instances that are gone. Check the Eureka dashboard and the client's own logs first, and compare what the registry reports for an instance (host, IP, port, status) with what other services can actually reach.
Shell
# Symptom -> likely cause -> fix

# Client logs "Cannot execute request on any known server" / 403 on registration
#   Eureka server has Spring Security with CSRF enabled.
#   -> csrf.ignoringRequestMatchers("/eureka/**") on the server; check credentials.

# Service registers but is not reachable (wrong host or an internal name)
#   Instance registered with a container hostname that other services cannot resolve.
#   -> eureka.instance.prefer-ip-address=true (under eureka.instance, not eureka.client);
#      or set eureka.instance.hostname / ip-address explicitly.

# Dashboard shows one instance although three are running
#   Identical instance ids overwrite each other (same host and port, or a random-port app).
#   -> make instance-id unique, include ${random.value} when server.port=0.

# Registration URL looks broken, with spaces in it
#   Folded YAML scalar (>) turned newlines into spaces.
#   -> keep the URL or instance-id on one line.

# Instance shows UP while it is actually broken
#   Eureka only sees heartbeats by default.
#   -> eureka.client.healthcheck.enabled=true, with a narrow readiness health group.

# Every instance goes DOWN together when the database is down
#   A shared dependency is part of the health group that drives registration.
#   -> keep external dependencies out of that group; use circuit breakers for them.

# Requests still reach a stopped instance for a minute or more
#   Caches: server response cache, client registry cache, LoadBalancer cache; lease expiry.
#   -> graceful shutdown, OUT_OF_SERVICE before stop, timeouts, retries for idempotent calls,
#      lower spring.cloud.loadbalancer.cache.ttl and registry-fetch-interval-seconds.

# Dashboard banner "RENEWALS ARE LESSER THAN THRESHOLD"
#   Self-preservation is active; dead instances are not evicted.
#   -> investigate network issues; in dev, enable-self-preservation=false.

# Unknown host: inventory-service from a @LoadBalanced client
#   The target is not registered, the name differs from spring.application.name,
#   or the client is not load-balanced (builder missing @LoadBalanced).
#   -> check /eureka/apps; names are case-insensitive but must match.

# Config in Consul KV is ignored
#   Missing spring.config.import: "optional:consul:" (Boot 2.4+), or wrong key path.
#   -> add the import; keys live under config/<application>/data.

# Startup fails with classpath / NoSuchMethodError in Spring Cloud classes
#   Spring Cloud release train does not match the Spring Boot version.
#   -> import the matching spring-cloud-dependencies BOM.