Dockerized Mock Environments

This page covers packaging mock API servers as Docker containers and wiring them together with Docker Compose — from a single WireMock service through to multi-service stacks integrated into a CI pipeline. It does not cover in-process interceptors or browser-layer mocking.


Prerequisites

  • Docker Desktop 24+ or Docker Engine 24+ with the Compose v2 plugin (docker compose version ≥ 2.20)
  • A working knowledge of YAML and basic Docker concepts (images, volumes, networks)
  • Stub mapping files in WireMock’s JSON format, or a willingness to generate them via record mode
  • (Optional) Familiarity with WireMock standalone configuration if you intend to author complex request-matching rules
  • (CI only) A pipeline runner that supports Docker-in-Docker or a Docker socket mount (GitHub Actions, GitLab CI, CircleCI all qualify)

Why containerise a mock server at all

Proxy-based mocking strategies intercept traffic at the TCP/HTTP layer rather than inside the application process. That means your application talks to a real socket, TLS handshakes happen, DNS resolves normally, and connection-level failure modes (reset packets, timeouts, refused connections) can be reproduced. Client-side tools like MSW setup excel at rapid UI iteration but cannot exercise this network path.

The diagram below shows where a containerised mock sits relative to the rest of a typical local-dev stack.

Dockerized mock environment architecture Traffic flow from the application under test through a Docker bridge network to the WireMock container, with an optional record path to the real upstream API. DOCKER COMPOSE STACK App Under Test localhost:3000 Docker bridge mock-net WireMock wiremock:8080 stub response Real upstream API (record mode only) proxy-all Named volume ./mappings + __files

Phase 1 — Core setup: a single WireMock service

Start with the simplest configuration that actually works before adding complexity.

Directory layout

project-root/
├── docker-compose.yml
└── wiremock/
    ├── mappings/          # stub JSON files committed to source control
    │   └── get-users.json
    └── __files/           # static response body files (optional)

Stub mapping file

{
  "mappings": [
    {
      "request": {
        "method": "GET",
        "url": "/api/users"
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "jsonBody": {
          "users": [
            { "id": "u1", "name": "Alice Nguyen", "role": "admin" },
            { "id": "u2", "name": "Ben Carter", "role": "viewer" }
          ]
        }
      }
    }
  ]
}

Core docker-compose.yml

version: "3.9"

services:
  wiremock:
    image: wiremock/wiremock:3.13.2
    container_name: wiremock
    # Run as the current user to avoid volume permission issues
    user: "${UID:-1000}:${GID:-1000}"
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - "--verbose"
      - "--port=8080"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s

networks:
  default:
    name: mock-net

Pin the image tag (3.13.2). Floating latest breaks reproducibility when WireMock ships a breaking change to its Admin API or default TLS behaviour.


Phase 2 — Configuration and wiring

Environment-variable-driven overrides

Real projects need different behaviour in development versus CI versus staging review environments. Use a .env file and an override compose file rather than editing the base docker-compose.yml.

.env (committed with safe defaults, .env.local gitignored for secrets):

WIREMOCK_TAG=3.13.2
WIREMOCK_PORT=8080
# Set to --record-mappings --proxy-all=https://api.example.com to enable record mode
WIREMOCK_EXTRA_ARGS=

docker-compose.override.yml (for local development only — also gitignored or conditionally applied):

version: "3.9"

services:
  wiremock:
    image: wiremock/wiremock:${WIREMOCK_TAG:-3.13.2}
    ports:
      - "${WIREMOCK_PORT:-8080}:8080"
    command:
      - "--verbose"
      - "--port=8080"
      - "${WIREMOCK_EXTRA_ARGS:-}"

Multi-service stack

When your application depends on more than one upstream (auth service, payments API, notification service), define a service per mock rather than stuffing all stubs into one WireMock instance. This keeps stub directories small, failure domains isolated, and port allocations explicit.

version: "3.9"

services:
  app:
    build: .
    environment:
      AUTH_API_URL: http://wiremock-auth:8080
      PAYMENTS_API_URL: http://wiremock-payments:8080
    depends_on:
      wiremock-auth:
        condition: service_healthy
      wiremock-payments:
        condition: service_healthy
    networks:
      - mock-net

  wiremock-auth:
    image: wiremock/wiremock:3.13.2
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - ./wiremock/auth/mappings:/home/wiremock/mappings
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s
    networks:
      - mock-net

  wiremock-payments:
    image: wiremock/wiremock:3.13.2
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - ./wiremock/payments/mappings:/home/wiremock/mappings
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s
    networks:
      - mock-net

networks:
  mock-net:
    driver: bridge

Services on the same named bridge network (mock-net) resolve each other by service name. The application container sets AUTH_API_URL=http://wiremock-auth:8080 — no host-port knowledge required, which removes a class of environment-specific bugs.

Persistent state vs ephemeral containers

Mock lifecycle management principles apply directly here. The right choice depends on the use case:

Use case Volume strategy Rationale
CI test run No named volume; bind-mount repo fixtures Guarantees determinism; container teardown cleans state
Local iterative QA Named volume for __files Persists recorded responses across restarts
Scenario-based testing No volume; reset via Admin API between suites Fastest; avoids filesystem I/O overhead

For persistent scenarios, use a named volume and configure cleanup in your CI teardown hook:

volumes:
  wiremock-state:

services:
  wiremock:
    image: wiremock/wiremock:3.13.2
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - wiremock-state:/home/wiremock/__files

Phase 3 — CI pipeline integration

The mock lifecycle management pattern for CI is: start → wait for healthy → test → reset → (repeat) → teardown. Never assume the container is ready after a fixed sleep; always use the healthcheck.

GitHub Actions example

name: Integration tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Export host UID/GID for volume permissions
        run: echo "UID=$(id -u)" >> $GITHUB_ENV && echo "GID=$(id -g)" >> $GITHUB_ENV

      - name: Start mock stack
        run: docker compose up -d --wait
        # --wait blocks until all healthchecks pass or the timeout is reached

      - name: Run integration tests
        run: npm test

      - name: Tear down mock stack
        if: always()
        run: docker compose down --volumes

docker compose up -d --wait (Compose v2.1+) waits until every service with a healthcheck reports healthy before returning. This is more reliable than polling in a shell loop.

GitLab CI example

integration:
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - apk add --no-cache docker-compose-plugin curl
  script:
    - export UID=$(id -u) GID=$(id -g)
    - docker compose up -d --wait
    - npm test
  after_script:
    - docker compose down --volumes

Resetting state between test suites

For test suites that run in-process (e.g. Jest with a global setup/teardown), reset WireMock’s request journal and scenario state between suites without restarting the container:

# Reset all stubs, scenarios, and request log
curl -s -X POST http://localhost:8080/__admin/reset

# Or reset only the request journal (keeps stubs intact)
curl -s -X DELETE http://localhost:8080/__admin/requests

Calling /__admin/reset takes under 5 ms and avoids the 2–10 second overhead of container restart, which matters when running hundreds of test cases.


The container lifecycle a mock has to survive Five stages: pulling a version-pinned image, mounting definitions read-only, passing a health gate, serving traffic, and draining on SIGTERM before exit. Each stage names the failure that appears when it is skipped, from an unpinned image drifting to a truncated request journal. Five stages; the first and the last are the two people skip. Pinned image never :latest or the stack drifts silently Read-only mounts definitions immutable immutable per run Health gate before any test not a fixed sleep Serving journal recording evidence for later SIGTERM drain archive, then exit or the journal is truncated An unpinned image means the same commit produces different behaviour on different days, which reads as flake.

Verification steps

  • docker compose up -d --wait exits with code 0 and all services show healthy in docker compose ps
  • curl -s http://localhost:8080/__admin/health | jq .status returns "healthy"
  • curl -s http://localhost:8080/api/users | jq .users[0].name returns "Alice Nguyen"
  • docker compose logs wiremock shows the matched stub ID and no "No mapping found" warnings for your test request
  • After curl -X POST http://localhost:8080/__admin/reset, curl http://localhost:8080/__admin/requests returns {"requests":[]}
  • docker compose down --volumes && docker compose up -d --wait produces identical responses, confirming determinism

How the application reaches the container Four networking arrangements. A Compose service name works between containers but not from the host. A published port works from the host and collides under parallelism. Host networking removes the boundary and the isolation together. A dedicated bridge per job isolates cleanly at the cost of one more thing to name. Arrangement Reachable from Cost Compose service name other containers only invisible from a host-run test published fixed port the host and containers collides across parallel jobs published dynamic port the host, after lookup the port must be read at runtime dedicated bridge per job that job only one more name to manage, and worth it Dynamic ports plus a per-job bridge is the combination that survives a parallel matrix without collisions.

Troubleshooting

Error: No mapping found for GET /api/users

WireMock received the request but found no matching stub. Common causes:

  • The stub JSON file is malformed — validate it with python3 -m json.tool wiremock/mappings/get-users.json
  • The volume mount path is wrong — check docker compose exec wiremock ls /home/wiremock/mappings to confirm files are present
  • The url field in the stub uses /api/users but the request hits /api/users?page=1 — switch to urlPathPattern: "/api/users" and add an ignoreExtraElements body matcher

Permission denied on volume mount at startup

WireMock’s official image runs as UID 1000 by default. If your host user is a different UID, the process cannot write to the mounted directory.

Fix: pass user: "${UID}:${GID}" in the service definition and export those variables before running Compose:

export UID=$(id -u) GID=$(id -g)
docker compose up -d --wait

service "wiremock" didn't reach healthy state after Xs

The healthcheck probe is failing. Inspect logs immediately after:

docker compose logs wiremock --tail 40

Common causes: the curl binary is not in the image (use wget -q -O- http://localhost:8080/__admin/health instead), the port is not yet bound, or an earlier flag is causing WireMock to exit on startup (check for flag typos in command:).

Port 8080 already in use on host

A local process (often another WireMock instance, a Spring Boot app, or a Jenkins agent) is already bound to 8080.

Fix: change the host-side port in docker-compose.yml — the container port stays 8080:

ports:
  - "9090:8080"   # host:container

Then update any localhost:8080 references in your application’s environment variables to localhost:9090.

docker compose up --wait hangs indefinitely

Compose --wait requires every service to have a healthcheck defined. If any service lacks one, Compose cannot determine when it is ready and blocks. Add a healthcheck to every service that your application depends_on, or upgrade to Compose v2.25+ where --wait-timeout gives you a hard ceiling.


When to advance

You are done with this setup when:

  • All stubs are committed to source control, the container starts cleanly from a fresh checkout, and CI runs produce identical results across machines
  • Stub mappings load automatically on container start with no manual API calls required
  • The application’s environment variables point at the Docker service name (e.g. http://wiremock:8080) rather than localhost, so the same config works in both local and CI environments
  • You can onboard a new engineer by running docker compose up -d --wait && npm test from a fresh clone

The natural next step is running WireMock in Docker Compose for more advanced scenarios: HTTPS termination, multi-profile stub loading, and response templating with Handlebars.


What containerisation buys, and what it costs

Running a mock in a container is not automatically right. It is right when specific properties are needed, and it is worth naming them so the decision is deliberate.

It buys reproducibility across machines. A pinned image behaves identically on a laptop, a colleague’s laptop and a CI runner, which removes an entire genre of “works on mine”. A locally installed binary does not — versions drift, and the drift is invisible until behaviour differs.

It buys reach. A container on a network is addressable by anything on that network: a browser, a backend service, a mobile emulator, a load generator. An in-process interceptor serves one runtime.

It buys a clean lifecycle. Start, health-check, stop, discard. Nothing survives unless a volume was declared, which makes isolation the default rather than something to remember.

It costs startup. Seconds per container, multiplied by however many times you start it. Paid once per job it is negligible; paid per test file it dominates.

It costs a networking decision. Service name or published port, fixed or dynamic, shared bridge or per-job. Every one of those is a choice that can be wrong in a way that only appears under parallelism.

It costs a second place for configuration to live. Definitions on a mount, flags in a command line, environment in a Compose file. That is three surfaces where a setting can be wrong, against one for an in-process mock.

The pattern that usually resolves the trade: containerise the mock when more than one runtime needs it or when it must behave identically everywhere, and keep an in-process interceptor for the unit suite that only ever runs in one language. The two coexist happily provided their definitions come from a single source, which is the one thing that must not be duplicated.

FAQ

Should I commit the wiremock-state Docker volume or keep it ephemeral?

Commit stub mapping JSON files under ./mappings to source control — those are your contract definitions. Keep __files (raw recorded response bodies) in a named volume or treat them as ephemeral. For CI, always start from a clean volume seeded by your repo’s fixtures to guarantee determinism across runs and machines.

How do I run WireMock in record mode inside Docker without network access issues?

Pass --record-mappings and --proxy-all=https://real-api.example.com to the WireMock entrypoint via the command: array. Map port 8080 to a host port and point your application at localhost:<host-port>. Ensure the container has egress to the real API’s host — if your Docker network has no internet access, use network_mode: bridge or add a DNS entry in your docker-compose.yml. Response shaping techniques covers how to post-process recorded responses before committing them.

Can I run multiple mock services on different ports in a single Compose stack?

Yes. Define each mock as a separate named service. Services on the same custom bridge network can address each other by service name (http://wiremock-auth:8080), so containers never need to know host port assignments. Only expose host ports that your local browser or external tooling actually needs.

What is the fastest way to reset WireMock state between test runs without restarting the container?

POST http://localhost:8080/__admin/reset — this clears the request journal and resets all scenarios to their initial state in under 5 ms. Pair it with a beforeAll or afterAll hook in your test framework. For journal-only resets (keep stubs active), use DELETE http://localhost:8080/__admin/requests.


← Back to Tool-Specific Implementation & Setup