DEV Community

Ian Ochieng
Ian Ochieng

Posted on

How I Tamed Legacy Docker (v1.29) for Multi-Stage Microservice Builds

Working with modern multi-stage Dockerfiles on legacy server environments can feel like fitting a square peg into a round hole. While updating the container infrastructure for flexiride-backend—a monorepo running 15 microservices—I ran into a wall with a legacy Docker Engine environment (v1.29.2).

Here is how I adapted our multi-stage build pipeline across 9 active microservices without requiring BuildKit, root privileges, or breaking our build architecture.


The Challenge: Modern Multi-Stage vs. Legacy Daemon

Our goal was straightforward: standardize Dockerfile.base across our active microservices and gateways (auth, user, driver, booking, matching, payment, notification, web-bff, and mobile-bff) to produce consistent :jvm runtime images.

However, legacy Docker v1.29.2 presented three hard constraints:

  1. No BuildKit Support: Modern caching mechanisms like --mount=type=cache failed instantly.
  2. Sequential Target Evaluation: Without BuildKit's smart DAG evaluation, legacy Docker couldn't skip late-stage GraalVM native image compilation steps, even when we only requested the intermediate JVM runtime target.
  3. Restricted Environment: No root privileges were available to upgrade the underlying local engine or daemon.

The Workarounds

1. Reordering Dockerfile.base Targets

Because legacy Docker processes build stages sequentially, putting heavy or optional downstream stages first forces the builder to run them regardless of target flags.

By reordering Dockerfile.base to prioritize JVM runtime stages before GraalVM steps, the build pipeline completes the JVM image cleanly before hitting unsupported toolchains.

2. Explicit Stage Targets in Docker Compose

To force legacy Docker to stop cleanly at the JVM stage, I declared explicit build targets across docker-compose.yml:

services:
  auth:
    build:
      context: .
      dockerfile: Dockerfile.base
      target: "false" # Explicitly targets JVM runtime stage on legacy engines
    image: flexirides/auth:jvm
Enter fullscreen mode Exit fullscreen mode

This stopped legacy Docker from executing past the JVM stage and kept image naming completely standardized across services (flexirides/<service-name>:jvm).


Verification & Results

After updating the build definitions, all 9 microservices compiled, tagged, and ran successfully.

1. Tagged Image Consistency

We verified that all 9 images compiled completely without errors and followed standardized naming conventions (flexirides/<service-name>:jvm):
Compiled Docker Images List

2. Service Bootstrapping

Spun up the auth microservice using docker-compose up -d auth and monitored its transition to an Up (healthy) state:
Docker Container Status

3. Spring Boot Actuator Health Check

Finally, we queried the service's health endpoint (GET http://localhost:8086/actuator/health) to confirm database connectivity and application readiness:
Spring Actuator Health Check

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "UP"
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Stage ordering matters on older Docker daemons that lack BuildKit's dynamic target skipping.
  • Explicit target declarations in docker-compose.yml prevent legacy engines from running unnecessary build stages.
  • Always validate microservice readiness with runtime health checks (like Spring Actuator) rather than assuming a successful docker build means a working container.

Have you had to support legacy Docker engines in your production environments? How did you handle multi-stage builds? Let's discuss below!

Top comments (0)