Everything so far, assembled: a MySQL database, a Spring Boot API and two single-page applications served by nginx, started with one command.
$ cd pizza
$ docker compose up -d --build
Container pizza-mysql-1 Healthy
Container pizza-api-1 Started
Container pizza-web-1 Started
React app http://localhost:8080
API http://localhost:8086
MySQL localhost:3309The database
mysql:
image: mysql:8.4
restart: unless-stopped
ports:
- "3309:3306"
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: pizza
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
volumes:
- mysql-data:/var/lib/mysqlMYSQL_DATABASE is not optional here. Liquibase creates the tables but not the schema
that holds them, so without it the API dies at start-up with
Unknown database 'pizza'.
command appends arguments to the image's own entrypoint — utf8mb4 end to end,
because MySQL's older utf8 is a three-byte encoding that cannot store an emoji, and a
pizza menu is exactly where someone will paste one.
The API
api:
build:
context: ./pizza-springboot-backend
target: runtime
image: pizza-api:dev
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
ports:
- "8086:8085"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/pizza?useSSL=false&allowPublicKeyRetrieval=true&connectionTimeZone=LOCAL&preserveInstants=false
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ""Every idea from the last four lessons in fifteen lines: a build context and a target stage (lesson 7), a health-gated dependency (lesson 15), a shifted host port with an unshifted container port (lesson 10), and configuration from the environment rather than a second properties file (lesson 12).
The published port is worth a note, because it is not how the browser reaches
the API. Neither frontend uses it. It is published so curl and swagger-ui work from
your terminal, and 8086 rather than 8085 because a developer running
./mvnw spring-boot:run already has 8085.
The web tier, and the decision that makes this simple
web:
build:
context: ./pizza-react-frontend
args:
VITE_API_BASE_URL: ""
image: pizza-web:dev
restart: unless-stopped
depends_on:
- api
ports:
- "8080:8080"VITE_API_BASE_URL: "" is the whole design in one line. Empty means the app requests
/api/... on its own origin. nginx handles the rest:
location /api/ {
proxy_pass http://api:8085;
proxy_http_version 1.1;
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;
}api is the service name on the compose network; 8085 is the port inside
that container. The published 8086:8085 is irrelevant — this request never reaches the
host.
The X-Forwarded-* headers matter more than they look. Without them the API sees
every request as originating from the nginx container's IP on the container network, so logs, rate
limits and any absolute URL it generates are all wrong.
What this buys: no CORS at all
The browser makes one kind of request — to localhost:8080, the origin it loaded the
page from. Same scheme, same host, same port. So there is no cross-origin request, no preflight, and
nothing to configure.
Compare the alternative, where the SPA calls http://localhost:8086 directly. Now
every request is cross-origin, the API must send Access-Control-Allow-Origin for
exactly the origin nginx is published on, and:
PIZZA_CORS_ALLOWED_ORIGINS: http://localhost:8080,http://localhost:4201becomes load-bearing. Change the published port and it silently breaks. This is far and away the
most common first failure of a containerised SPA — it works with npm run dev, whose
port is in the allowlist, and returns a blank page in a container.
Proxying makes the problem not exist. It is also what production looks like anyway, where one domain serves both.
And the SPA rewrite rule
location / {
try_files $uri $uri/ /index.html;
}The router owns /menu and /checkout; they are not files. Without this,
a visitor who reloads on /checkout or arrives from a bookmark gets a 404 from a
perfectly working application. Every static host needs some version of this rule.
It is the last location on purpose — /api/ and
/assets/ match first, or the fallback would swallow the API calls and return HTML to
something expecting JSON.
Verifying it, properly
curl gets you part of the way:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/checkout
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/products
200But 200 from curl proves nginx is answering, not that the application works.
The failure modes that matter here — a build arg that did not take, a proxy pointing at the wrong
service — all still return 200 with a perfectly valid index.html.
So the check that counts drives a browser:
$ node verify_stack.mjs
PASS React (web)
product cards 14
/api/ requests 3, all same-origin: true
PASS Angular (web-angular)
product cards 14
/api/ requests 3, all same-origin: trueProduct cards rendered means the JavaScript booted and the API answered. "All same-origin" means the proxy is genuinely carrying the traffic rather than an absolute URL being baked in somewhere.
What this file is still not good enough for
Being clear about this matters, because a working compose file is easy to mistake for a deployment.
The database has an empty root password. A secret is committed in plain text. There are no resource limits, so one container can starve the others. There is no TLS. Log output goes to Docker's default json-file driver with no rotation. And the whole stack is on one machine — if it dies, everything is down, and there is no way to deploy a new version without dropping requests.
Lesson 21 goes through which of those Docker can fix and which need something above it.