Series overview
Part 2 of 1315% complete
2026-07-22•6 min read

A reproducible test environment

By the end of this chapter you have the lab’s repository layout, a compose.yaml that starts PostgreSQL, Prometheus, and Grafana with one command, and — the part that actually makes results comparable — a written record of every variable that affects a measurement. The application itself arrives in chapter 04; this chapter builds the ground it stands on.

Prerequisites: Docker Engine with Compose v2 (docker compose version), roughly 4 GB of free memory for containers, and nothing else listening on ports 5432, 9090, or 3000.

Why the environment comes before the tests

A load-test number is a property of a system, not of code. The same Order API produces different p99 latencies depending on whether PostgreSQL has two vCPUs or eight, whether the buffer cache is warm, whether the dataset fits in RAM, and whether the load generator shares the machine. If any of those differ between two runs, the runs measure different systems and comparing them is noise.

That is why this series treats the environment as code plus a checklist: the Compose file pins the software, and a short document — written before each significant run — pins everything else.

Repository layout

The whole series lives in one directory:

orders-perf-lab/
├── .env
├── compose.yaml
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/ # wrapper, added in chapter 04
├── db/
│ └── init/ # mounted into PostgreSQL at first boot
│ ├── 01-schema.sql # chapter 04
│ └── 02-seed.sql # chapter 05
├── prometheus/
│ └── prometheus.yml
├── grafana/
│ └── provisioning/
│ └── datasources/
│ └── prometheus.yml
└── src/
├── main/ # the Order API — chapter 04
│ ├── java/in/o612/eng/orders/…
│ └── resources/application.yml
└── gatling/ # simulations — chapter 06
├── java/in/o612/eng/orders/load/…
└── resources/data/…

The application runs on the host — via ./gradlew bootRun — and the infrastructure runs in containers. This is a deliberate trade-off: rebuilding and restarting a container on every code change would slow the experiment loop in chapters 09–11, where you will restart the API dozens of times. The cost is realism, and chapter 12 moves the same service into Kubernetes to close that gap. Record which mode each run used.

The Compose file

Create orders-perf-lab/compose.yaml:

compose.yaml
name: orders-perf-lab
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: orders
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orders -d orders"]
interval: 5s
timeout: 3s
retries: 10
prometheus:
image: prom/prometheus:v3.7.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=7d
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: grafana/grafana:12.2.0
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
ports:
- "3000:3000"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
volumes:
pgdata:

And .env, which Compose reads automatically:

.env
# Local lab only. Never commit real credentials.
POSTGRES_PASSWORD=orders-local-pw

Three details carry weight here:

  • db/init/ is mounted read-only into PostgreSQL’s init directory. Files there run once, when the data volume is empty — schema in chapter 04, seed data in chapter 05. Re-running them means destroying the volume, which is exactly the reset semantics you want: docker compose down -v gives you a known-empty database.
  • The volume mounts at /var/lib/postgresql, not /var/lib/postgresql/data. From PostgreSQL 18 the image stores data in a major-version subdirectory (PGDATA=/var/lib/postgresql/18/docker) and refuses to start if a mount sits at the old /var/lib/postgresql/data path — the single change that makes this Compose file version-specific.
  • extra_hosts: host-gateway lets Prometheus, inside its container, reach the Spring Boot app running on the host at host.docker.internal:8080. On Docker Desktop for Mac or Windows this hostname already exists; on Linux it needs the mapping above. Skip it and Prometheus scrapes nothing, silently.
  • Anonymous Grafana admin is a lab shortcut. It must never appear in a shared environment; chapter 12 covers the access controls that apply when this stack leaves your laptop.

Prometheus scrape configuration

prometheus/prometheus.yml scrapes two targets: itself, and the application’s /actuator/prometheus endpoint (which chapter 03 adds):

prometheus/prometheus.yml
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: order-api
metrics_path: /actuator/prometheus
static_configs:
- targets: ["host.docker.internal:8080"]
labels:
application: order-api
instance: local

The 5-second interval is finer than Prometheus defaults. For load tests you want resolution inside the run — a 15-second scrape can miss the beginning of a latency ramp. The cost is trivial at this scale. The application and instance labels matter later: when chapter 12 moves to Kubernetes and multiple pods report, they are what keep one pod’s metrics from being averaged into another’s.

Grafana datasource provisioning

grafana/provisioning/datasources/prometheus.yml wires Grafana to Prometheus so a dashboard works on first boot instead of after manual clicking:

grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true

Note the URL: Grafana reaches Prometheus by service name over the Compose network — prometheus:9090, not localhost. Inside a container, localhost is the container itself; this is the single most common Compose-networking mistake.

Start it, verify it

Terminal window
docker compose up -d
docker compose ps # postgres healthy; prometheus and grafana up

Verification, not assumption:

  1. curl http://localhost:9090/-/ready — Prometheus reports ready.
  2. http://localhost:9090/targets — the order-api target will be DOWN (the app does not exist yet). That is expected now; it must be UP before any test run in chapter 07.
  3. http://localhost:3000 — Grafana opens without a login prompt, with a Prometheus datasource under Connections → Data sources.
  4. docker compose exec postgres psql -U orders -d orders -c 'select 1' — the database answers.

Why this laptop is not your production cluster

The lab controls the variables that make runs repeatable; it does not reproduce production. The differences that matter:

  • Shared machine. The app, the database, Prometheus, and soon Gatling all compete for the same cores. Gatling itself can consume a CPU at high rates — when the client saturates, it stops measuring latency and starts adding to it. Chapter 07 shows how to check for this.
  • No network distance. Loopback adds under a millisecond; a cloud availability zone adds more, and an internet path adds far more. Server-side latency transfers roughly; client-observed latency does not.
  • Tiny, clean storage. A fresh NVMe-backed Docker volume is not a managed disk with IOPS limits, and fsync behaviour differs.
  • No neighbours. Production VMs and Kubernetes nodes share hosts. Nothing here is throttled unless you throttle it.
  • Cold caches until warmed. PostgreSQL’s shared buffers and the JVM’s JIT compiler both need warm-up — a configured, measured phase of every run, not an afterthought.

The numbers the lab produces answer “did this change make the service faster here?” They do not produce a production capacity figure. For that, the same method runs in a staging environment that resembles production — chapter 12.

The variables you must control

Every difference below is a confound — something that changes results without being the change you intended:

VariableWhy it changes resultsHow this lab controls it
CPU allocationThrottling distorts all latencyDocument cores available to Docker; later, container limits
Memory limitsTriggers OOM or pressure-induced GCDocument Docker memory cap and JVM -Xmx
JVM flags / GC algorithmChanges pause behaviour directlyFixed in application.yml / Gradle config per run
Dataset size and distributionIndex depth, cache hit ratio, query plansDeterministic seed script (chapter 05), recorded row counts
Warm-up stateJIT and buffer cache dominate early minutesFixed warm-up phase, discarded from results (chapter 07)
PostgreSQL configshared_buffers, max_connections, WAL settingsRecorded; changed only deliberately
External dependenciesUncontrolled latency and failuresNone in the baseline; mocked if added
Concurrent load on the hostOther processes steal CPUNote anything heavy running; keep the machine otherwise idle
Load generatorA saturated client inflates client-side latencyWatch Gatling machine’s CPU during runs

The run sheet

Before every significant test run, copy this checklist into a file named with the run’s date and purpose — runs/2026-09-26-baseline.md — and fill it in. Sixty seconds now is what makes “the regression appeared in October” diagnosable in December.

runs/TEMPLATE.md
# Run sheet: <purpose>
- Git commit:
- App config changed since last run: (diff or "none")
- JVM flags: -Xmx: GC: other:
- Docker resources: CPUs: Memory:
- Postgres: version, shared_buffers, max_connections
- Dataset: customers= orders= items= (row counts, seed version)
- Simulation + injection profile:
- Warm-up / steady / cool-down durations:
- Load generator location and CPU headroom observed:
- Other significant load on the host:
- Result (p50/p95/p99/err% per endpoint, achieved throughput):
- Verdict vs SLO:

Milestone check: you should now have Prometheus and Grafana answering on their ports, a healthy empty PostgreSQL, and a runs/ directory with the template saved. Chapter 03 adds the application-side instrumentation that gives Prometheus something to scrape.

Spring BootJavaPerformanceTesting

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind