Docker Compose – The Whole Application in One File

August 2, 20264 min readUpdated 8/21/2026

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:3309

The 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/mysql

MYSQL_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:4201

becomes 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
200

But 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: true

Product 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.

Next: getting the image off your laptop.