Spring Boot, MySQL and React with Docker Compose
Published Updated DevOps 12 min read
A three-service stack in one file: healthcheck-gated startup so the API does not race the database, a React build served by nginx with the API proxied behind it, and a dev override that keeps hot reload.
Three services, one command. The parts that take thought are not the Dockerfiles. It is that the API starts before MySQL can accept connections, and that a React application built into static files needs something to serve it and a way to reach the API without tripping over CORS.
Written against Docker Compose v2, Spring Boot 3.2, Node 20 and MySQL 8.4.
Layout
notes/
├── compose.yaml
├── compose.override.yaml # development-only, picked up automatically
├── .env
├── api/
│ ├── Dockerfile
│ └── pom.xml
└── web/
├── Dockerfile
├── nginx.conf
└── package.json
compose.yaml is the current filename, docker-compose.yml still works and is the older
convention. compose.override.yaml is merged on top automatically when present, which is how one
stack serves both development and production without duplicating the whole file.
The API image
# api/Dockerfile
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B -DskipTests package
FROM eclipse-temurin:17-jre AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
EXPOSE 8080
USER 1001:1001
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
COPY pom.xml before COPY src so the dependency download is cached until the pom changes. The
layered extraction means a code change rebuilds kilobytes rather than the whole fat jar: both covered
in more detail in Dockerizing a Spring Boot application.
The exec-form ENTRYPOINT matters here specifically: Compose sends SIGTERM on docker compose down,
and the shell form would leave sh as PID 1 absorbing it.
The web image
A React build is static files. Build them with Node, serve them with nginx, and ship neither Node nor the source:
# web/Dockerfile
FROM node:20-alpine AS build
WORKDIR /src
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /src/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
npm ci rather than npm install: it installs exactly what the lock file says and fails if the lock
and the manifest disagree, which is what you want in a build. COPY package*.json first, for the same
cache reason as the pom.
/src/dist is Vite’s output. Create React App writes build: check which before wondering why the
image serves an nginx welcome page.
# web/nginx.conf
server {
listen 80;
# single-page app: unknown paths are routes, not missing files
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# the API is reached through this origin, so the browser never sees a cross-origin request
location /api/ {
proxy_pass http://api:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Two decisions in that file.
try_files $uri $uri/ /index.html is what makes client-side routing work. Without it, a reload on
/notes/42 asks nginx for a file that does not exist and gets a 404: the single most common
“works when I click, breaks when I refresh” bug in a containerised SPA.
Proxying /api/ through nginx removes CORS entirely. The browser only ever talks to one origin,
so there is no preflight and no @CrossOrigin anywhere in the Spring code. The alternative —
the front end calling http://localhost:8080 directly: needs CORS configuration, and that
configuration then has to change per environment.
proxy_pass http://api:8080/ uses the service name. Compose provides DNS for it on the shared
network; localhost inside the nginx container is the nginx container.
The compose file
# compose.yaml
name: notes
services:
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1",
"-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 30s
api:
build: ./api
restart: unless-stopped
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/${MYSQL_DATABASE}?useSSL=false&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
SERVER_SHUTDOWN: graceful
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health/readiness"]
interval: 10s
retries: 6
start_period: 40s
web:
build: ./web
restart: unless-stopped
ports:
- "3000:80"
depends_on:
api:
condition: service_started
volumes:
db-data:
# .env
MYSQL_DATABASE=notes
MYSQL_USER=notes
MYSQL_PASSWORD=notes-dev-password
MYSQL_ROOT_PASSWORD=root-dev-password
The healthcheck is the whole point
depends_on: [db] in its short form waits for the container to start, not for MySQL to accept
connections. MySQL takes several seconds to initialise on first run, so the API starts, fails to
connect, and exits. Run docker compose up a second time and it works, because the database is
already initialised, which is exactly why this bug survives so long. It looks intermittent.
condition: service_healthy waits for the healthcheck to pass. start_period: 30s stops the early
failures counting toward retries while the database is still setting up its data directory.
Note -h 127.0.0.1 in the ping command. Without a host, mysqladmin connects over the local socket,
which responds before the server is accepting TCP connections, so the healthcheck passes while the
API still cannot connect. That is a subtle way to reintroduce the bug you just fixed.
Only one service publishes a port
web maps 3000:80. Neither api nor db publishes anything, and they do not need to: services on
the same Compose network reach each other by name on their container ports. Publishing MySQL’s 3306
makes your development database reachable from the network you are on, which at a conference or on a
shared network is a real exposure.
Development override
# compose.override.yaml
services:
api:
build:
context: ./api
target: build # stop at the Maven stage
command: mvn -B spring-boot:run
volumes:
- ./api/src:/src/src:ro
- maven-cache:/root/.m2
ports:
- "8080:8080" # exposed for direct calls and a debugger
environment:
SPRING_JPA_HIBERNATE_DDL_AUTO: update
LOGGING_LEVEL_COM_EXAMPLE: DEBUG
web:
build:
context: ./web
target: build
command: npm run dev -- --host 0.0.0.0
volumes:
- ./web/src:/src/src:ro
- /src/node_modules
ports:
- "5173:5173"
volumes:
maven-cache:
target: build reuses the earlier stage of the same Dockerfile, so development runs on the image that
already has a compiler, no second Dockerfile to keep in step.
- /src/node_modules is an anonymous volume, and it is there to stop the host directory shadowing
the container’s node_modules. Without it, bind-mounting the source hides the modules installed
during the build and every import fails. It is the single most common Compose-plus-Node problem.
Mount source read-only. A container writing into your working tree as root is a mess to clean up.
For production, skip the override explicitly:
$ docker compose -f compose.yaml up -d --build
Running it
$ docker compose up --build --progress plain
#1 [db] pulling mysql:8.4 ... done
#2 [api internal] load build definition ... done
#3 [web internal] load build definition ... done
Container notes-db-1 Waiting
Container notes-db-1 Healthy
Container notes-api-1 Started
Container notes-web-1 Started
--progress plain is worth knowing beyond the readability: the default animated output rewrites lines
in place, so a CI log captures a mangled single line rather than the sequence of steps.
$ docker compose ps
NAME IMAGE STATUS PORTS
notes-db-1 mysql:8.4 Up 2 minutes (healthy)
notes-api-1 notes-api Up 1 minute (healthy)
notes-web-1 notes-web Up 1 minute 0.0.0.0:3000->80/tcp
$ curl -s localhost:3000/api/notes | head -c 100
$ docker compose logs -f api
docker compose ps showing (healthy) rather than just Up is the confirmation that the gating
worked.
Two commands worth distinguishing:
$ docker compose down # stops and removes containers; the volume survives
$ docker compose down -v # also deletes the volume — your database is gone
-v is the one people run while trying to force a clean rebuild. It works, and it takes the data with
it.
Frequently asked questions
Why does the API fail on the first up and work on the second?
Short-form depends_on waits for
the container to start, not for MySQL to accept connections. Add a healthcheck and
condition: service_healthy.
Why does my healthcheck pass while the API still cannot connect?
mysqladmin ping without -h
uses the local socket, which is ready before TCP. Add -h 127.0.0.1.
Why is a page reload 404ing in my React app?
nginx is looking for a file at that path. Add
try_files $uri $uri/ /index.html so unknown paths fall through to the SPA.
How do I avoid CORS configuration?
Proxy /api/ through the same nginx that serves the front end.
One origin means no preflight and no @CrossOrigin.
Why can the API not reach the database at localhost?
localhost inside a container is that
container. Use the Compose service name, db, which resolves on the shared network.
Should I publish the database port?
No. Services reach each other by name on the internal network. Publishing 3306 exposes your development database to whatever network you are on.
Why do my imports fail when I bind-mount the source?
The host directory shadows the container’s
node_modules. Add an anonymous volume at that path: - /src/node_modules.
How do I keep hot reload without a second Dockerfile?
target: a build stage in
compose.override.yaml and override command. The override file is merged automatically.
How do I run production without the override?
Name the file explicitly:
docker compose -f compose.yaml up -d.
What is the difference between down and down -v?
down removes containers and keeps volumes;
-v deletes the volumes too, including your database.
Where should I go next?
Dockerizing a Spring Boot application covers the API image in depth, and deploying on Kubernetes covers what replaces Compose in production.