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:
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:
# Local lab only. Never commit real credentials.POSTGRES_PASSWORD=orders-local-pwThree 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 -vgives 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/datapath — the single change that makes this Compose file version-specific. extra_hosts: host-gatewaylets Prometheus, inside its container, reach the Spring Boot app running on the host athost.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):
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: localThe 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:
apiVersion: 1datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: trueNote 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
docker compose up -ddocker compose ps # postgres healthy; prometheus and grafana upVerification, not assumption:
curl http://localhost:9090/-/ready— Prometheus reports ready.http://localhost:9090/targets— theorder-apitarget will be DOWN (the app does not exist yet). That is expected now; it must be UP before any test run in chapter 07.http://localhost:3000— Grafana opens without a login prompt, with a Prometheus datasource under Connections → Data sources.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
fsyncbehaviour 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:
| Variable | Why it changes results | How this lab controls it |
|---|---|---|
| CPU allocation | Throttling distorts all latency | Document cores available to Docker; later, container limits |
| Memory limits | Triggers OOM or pressure-induced GC | Document Docker memory cap and JVM -Xmx |
| JVM flags / GC algorithm | Changes pause behaviour directly | Fixed in application.yml / Gradle config per run |
| Dataset size and distribution | Index depth, cache hit ratio, query plans | Deterministic seed script (chapter 05), recorded row counts |
| Warm-up state | JIT and buffer cache dominate early minutes | Fixed warm-up phase, discarded from results (chapter 07) |
| PostgreSQL config | shared_buffers, max_connections, WAL settings | Recorded; changed only deliberately |
| External dependencies | Uncontrolled latency and failures | None in the baseline; mocked if added |
| Concurrent load on the host | Other processes steal CPU | Note anything heavy running; keep the machine otherwise idle |
| Load generator | A saturated client inflates client-side latency | Watch 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.
# 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.