Demystifying the Dockerfile: Running Multiple Spring Boot Services with Docker Compose
Be honest: how much of your Dockerfile do you actually understand?
We all use Docker now. Every project has a Dockerfile, every repo has a docker-compose.yml, and most of us run docker compose up a dozen times a day. But a lot of those files were copied from another project, a blog post or a teammate, and they work, so nobody touches them. Until something breaks.
That was me for a long time. I could use Docker fine, but I couldn’t explain why my image was so big, why a service crashed only on the first start, or why Kafka worked from my laptop but not from another container.
So in this article, let’s open up the files we copy-paste every day and go through them line by line. We’ll use a realistic setup, two Spring Boot services plus Postgres and Kafka, and I’ll point out the three things that confused me the most along the way.
First, the idea that made it click for me
When I started, I thought I needed one big Dockerfile that starts everything. You don’t. A Dockerfile builds one image, and that image runs one thing.
So here’s how the pieces split up. Your own Spring Boot apps each get a Dockerfile, because you’re building them. Postgres and Kafka you don’t build at all, you just pull the official images. And a single docker-compose.yml ties it together: it starts everything in the right order and puts it all on one shared network.
For this article we’ll use a small, realistic setup. An order service saves orders in Postgres and calls a payment service over HTTP, and both of them publish events to Kafka.
Notice that your laptop only talks to the order service through a published port. Everything inside the box talks by name. Keep that picture in mind; it’ll explain most of what follows.
On disk, the project is nothing fancy:
project/
├── docker-compose.yml
├── order-service/ (Dockerfile, pom.xml, src/)
└── payment-service/ (Dockerfile, pom.xml, src/)Step 1: A Dockerfile for each service
My first Dockerfile was about 700 MB, because I shipped Maven, the JDK and all my source code into production. Don’t be me. Use a multi-stage build: one stage compiles the app, and a second, clean stage keeps only the JAR and a Java runtime.
Drop this into both order-service/ and payment-service/:
# ---- build stage ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests# ---- run stage ----
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]A quick tour of what’s going on.
The first FROM pulls an image with Maven and JDK 21 and names it build, so the second stage can grab files from it later.
Then comes my favourite trick. We copy only pom.xml and download the dependencies before copying the code. Docker caches that step, so as long as you haven't touched pom.xml, rebuilds skip the whole download. On my machine that took rebuilds from a couple of minutes to a few seconds.
After that we copy src and package the JAR. I skip tests here because CI already runs them.
The second FROM starts fresh with just a Java runtime, and COPY --from=build pulls the JAR across. Nothing else comes along.
One thing that confused me early on: EXPOSE 8080 doesn't actually open any port. It's documentation. Compose is what publishes ports, as you'll see in a minute.
Also add a small .dockerignore next to each Dockerfile. Without it, Docker sends your whole target/ folder and IDE files along with every build, and you'll wonder why it's so slow:
target/
.idea/
*.iml
.git/Step 2: One Compose file to run them all
This is the file that replaces my old morning ritual. It lives in the project root, and every entry under services becomes a container:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: orders
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d orders"]
interval: 5s
retries: 10
kafka:
image: apache/kafka:3.8.0
ports: ["9092:9092"]
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
payment-service:
build: ./payment-service
ports: ["8082:8080"]
environment:
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
depends_on: [kafka]
order-service:
build: ./order-service
ports: ["8081:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/orders
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: secret
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
PAYMENT_SERVICE_URL: http://payment-service:8080
depends_on:
postgres:
condition: service_healthy
kafka:
condition: service_started
payment-service:
condition: service_started
volumes:
pgdata:It looks like a lot, but it’s simpler than it seems. Let’s go through it.
postgres is just the official image. The environment lines create the database and user the first time it starts. The pgdata volume is what keeps your data around when you recreate the container. I learned that one the hard way, after losing a day's test data. The healthcheck will matter in a moment, so park it for now.
kafka runs a single broker in KRaft mode. If you’ve used older tutorials, you’ll notice there’s no ZooKeeper container anymore. Those listener settings look scary, and honestly they caused me the most pain of anything here. They get their own section below.
payment-service and order-service use build instead of image. That tells Compose to build them from the Dockerfile in that folder. Look at the order service's environment variables: they point at postgres, kafka and payment-service. Those aren't typos. They're the service names, and they work as hostnames.
How do the containers find each other?
This is where most setups break, so it’s worth slowing down. Compose puts all your services on one private network, and each one gets a hostname equal to its service name. Three things follow from that.
Stop using localhost between services. This one got me for an embarrassingly long time. My app worked perfectly in IntelliJ and failed instantly in Docker. Why? Inside a container, localhost means that container itself, not your laptop. So the order service has to reach the database at postgres:5432 and the payment service at http://payment-service:8080.
ports are for you, not for the containers. When you write "8081:8080", you're saying "forward port 8081 on my laptop to port 8080 in the container". That's how you reach the app from Postman. The containers themselves ignore the left-hand number. That's also why both services can happily listen on 8080 without fighting.
Get Basuki Nath’s stories in your inbox
Join Medium for free to get updates from this writer.
The little table I wish someone had shown me on day one:
Let environment variables do the switching. Spring Boot maps SPRING_DATASOURCE_URL to spring.datasource.url for you. So I keep plain localhost values in application.yml for running from the IDE, and Compose quietly overrides them inside Docker. It works for your own properties too, so PAYMENT_SERVICE_URL becomes payment.service.url:
@Value("${payment.service.url:http://localhost:8082}")
private String paymentServiceUrl;The bit after the colon is a fallback. Same code, works in the IDE and in Docker, no profiles to juggle.
“It crashes the first time, then works on restart”
If you’ve seen this, you’re in good company. Here’s what’s happening. A plain depends_on: [postgres] only waits until the Postgres container has started. But Postgres needs a few more seconds before it'll accept connections. Spring Boot is quick, tries to connect straight away, gets Connection refused, and falls over.
The fix has two halves. First, the healthcheck we parked earlier tells Docker how to check that Postgres is actually ready (pg_isready does exactly that). Second, the order service says it wants to wait for that check, not just for the container to exist:
depends_on:
postgres:
condition: service_healthyThere are three conditions you can wait on, and honestly, the first two cover 95% of what you’ll need:
ConditionWaits untilservice_startedthe container has started (the default)service_healthythe container's healthcheck passesservice_completed_successfullya one-off job, such as a migration, has exited with code 0
One caveat: this is a convenience for local development, not a guarantee. In a real environment, services restart and connections drop. Your apps should still retry on their own, with Spring Retry or Resilience4j around calls to other services.
The Kafka bug that ate my afternoon
This one deserves its own section, because it doesn’t make sense until someone explains it.
When your app connects to Kafka, it actually connects twice. First it reaches the bootstrap server you configured, kafka:9092. Kafka then replies, "great, from now on talk to me at this address", and the client reconnects to whatever address Kafka hands back. That address is the advertised listener.
A lot of examples online advertise localhost:9092. So your order service connects fine, gets told "use localhost", and then goes looking for Kafka inside its own container. You get timeouts, or Connection to node -1 could not be established, and the first connection worked, which is what makes it so confusing. I spent a whole afternoon convinced my Spring config was wrong.
The fix is one line:
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092Now Kafka tells every client to use kafka:9092, which every container can resolve. The rest of the settings just run one node in KRaft mode. KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 is there because the default is 3, and a single broker can't satisfy that.
What if you also want to hit Kafka from your laptop? Say you want to poke at topics with a CLI tool. Your laptop has no idea what kafka means, so give it its own listener:
ports: ["29092:29092"]
envirorrnment:
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,HOST://:29092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,HOST://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXTContainers keep using kafka:9092, and anything on your laptop uses localhost:29092. Everyone's happy.
Running it (and fixing it when it breaks)
These are the commands I actually use day to day, run from the project root:
docker compose up --build # build images and start everything
docker compose up -d # same, in the background
docker compose ps # what is running, and health status
docker compose logs -f order-service
docker compose up --build order-service # rebuild and restart one service
docker compose exec postgres psql -U app -d orders
docker compose down # stop and remove containers
docker compose down -v # ...and delete the database volumeAnd here’s my cheat sheet of errors. I’ve hit every single one of these, some of them more than once:
SymptomLikely causeFixConnection refused to localhost:5432The app still points at localhostUse the service name: postgres:5432Spring Boot crashes on startup, then works on restartIt started before Postgres was readyAdd a healthcheck and condition: service_healthyKafka timeouts after the first connectionBroker advertises localhostSet KAFKA_ADVERTISED_LISTENERS to kafka:9092port is already allocatedSomething on your laptop already uses that host portChange the left-hand port, such as "5433:5432"Code changes do not show upThe old image is reusedRun with --buildOld data or schema keeps coming backThe named volume persistsdocker compose down -v (deletes the data)
That’s it
That’s the whole setup. Once it’s in your repo, a new teammate can clone it, run docker compose up --build, and have the whole system running before their coffee's cold. No more first day lost to setup.
If you remember just four things from this article, make it these. Each container runs one service. Service names are your hostnames, so drop localhost between containers. Started isn't the same as ready, so use healthchecks. And Kafka needs to advertise an address that clients can actually reach.
Want to take it further? Here’s what I added next in my own projects. I moved the passwords into a .env file that stays out of Git. I put a Spring Cloud Gateway in front as a single entry point. I added Kafka UI and pgAdmin as extra containers so I could see topics and tables in the browser. And I used Testcontainers to spin up the same Postgres and Kafka images in integration tests.
Your turn: read this Dockerfile line by line
Here’s a small homework before you go. Below is a complete Dockerfile for a Spring Boot app. It uses everything we covered, plus a few lines we didn’t talk about. Every line has a comment, so try reading it top to bottom and make sure you could explain each one to a teammate. If a line still feels like magic, that’s your next thing to look up.
# ============ STAGE 1: build the app ============
# Start from an image that already has Maven and Java 21 installed.
# "AS build" gives this stage a name so we can copy from it later.
FROM maven:3.9-eclipse-temurin-21 AS build
# Every command after this runs inside /app (Docker creates it for us).
WORKDIR /app
# Copy ONLY pom.xml first and download dependencies.
# Docker caches this step, so it only re-runs when pom.xml changes.
COPY pom.xml .
RUN mvn -q dependency:go-offline
# Now copy the source code and build the JAR (tests run in CI, not here).
COPY src ./src
RUN mvn -q package -DskipTests
# ============ STAGE 2: run the app ============
# Start again from a much smaller image: just a Java runtime, no Maven, no source code.
FROM eclipse-temurin:21-jre
WORKDIR /app
# Don't run as root. Create a normal user and switch to it.
RUN useradd --system --create-home appuser
USER appuser
# Take only the finished JAR from the "build" stage.
COPY --from=build /app/target/*.jar app.jar
# Default settings. Compose or `docker run -e` can override any of these.
ENV SPRING_PROFILES_ACTIVE=docker
ENV JAVA_OPTS="-Xms256m -Xmx512m"
# Documentation only: tells readers the app listens on 8080.
# Publishing the port happens in docker-compose.yml ("8081:8080").
EXPOSE 8080
# The command that starts the app when the container starts.
# "sh -c" is used so that $JAVA_OPTS gets expanded.
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]Three lines here are new compared to the rest of the article, so here’s a hint for each. USER appuser means that if someone ever breaks into your app, they don't get root inside the container. ENV sets defaults that Compose can override, exactly like the SPRING_DATASOURCE_URL trick from earlier. And sh -c in the ENTRYPOINT exists because the plain JSON form doesn't expand variables like $JAVA_OPTS.
Once this file makes sense to you, open the Dockerfile in one of your own projects and read it the same way. I’d bet you’ll find at least one line you can now improve.
If this saved you an afternoon, give it a clap and follow along. I’m writing more hands-on Spring Boot and Kafka pieces, and I’d love to hear in the comments which part tripped you up.








