SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-hosted API mocking and testing on a VPS

Mock servers and API test runners do different jobs. Run both on your own box: WireMock stubs in git, Hurl suites in CI, and reports that survive a rebuild.

Two jobs that share one repository

Self-hosted API mocking and testing are two different jobs, and treating them as one wastes a week. A mock server stands in for a dependency you cannot call from CI: a payment provider, a partner API, a rate limited upstream, a service another team has not shipped yet. An API test runner calls your own endpoints in a fixed order and asserts on the responses, carrying values from one response into the next request.

The two do not overlap. A mock server never reports pass or fail. A test runner has no opinion about what a payment provider returns when a card is declined. Most teams that already rent a box end up running one of each, started by the same Docker Compose file and reviewed in the same pull request.

Why self-host API mocking and testing?

Your fixtures are production shaped data. A request body in an API test is a real customer record with the name changed, or with the name not changed because nobody checked. Recorded stubs are worse: proxy recording stores whatever the upstream actually returned, so a stub directory built by recording holds live tokens and customer email addresses until someone reads every file. On a hosted service that data becomes somebody else's incident and your disclosure.

The second reason is reachability. A service bound to a private address is not reachable from a hosted runner, so the test cannot run at all. Every workaround costs something. Publishing the API to the internet in order to test it removes the reason it was private. A tunnel or a public staging copy is another system to maintain, and a staging copy drifts from production between releases. A runner on the same private network calls the service directly and needs none of that, which is the practical argument behind a self-hosted GitHub Actions runner.

Which self-hosted mock server should you run?

Each of these runs as a container on a box you own. The question that matters is what each one treats as the source of truth, because that decides whether rebuilding the container costs you nothing or costs you an afternoon.

  • WireMock keeps every stub as a JSON file in a mappings/ directory, with large response bodies in __files/. The image is wiremock/wiremock, its root directory inside the container is /home/wiremock, and it also runs as a recording proxy. Files on disk means the mock lives in git like any other code.
  • Mockoon CLI keeps a whole mock API in one JSON data file. Install it with npm install -g @mockoon/cli and start it with mockoon-cli start --data ./data-file.json, or run the mockoon/cli image with that file bind mounted. The desktop app edits the same file, so designing in a UI and committing the result stay compatible.
  • MockServer runs from the mockserver/mockserver image and listens on port 1080. Expectations arrive over its own REST API, which is handy from test code and risky as a deployment: an expectation created by an HTTP call disappears when the container restarts. Use its JSON initialization file for stubs that are meant to be permanent.
  • Prism builds the mock from your OpenAPI document instead of from separate stub files. Install it with npm install -g @stoplight/prism-cli, then run prism mock openapi.yaml. Inside a container add -h 0.0.0.0, because Prism binds to localhost by default and is otherwise unreachable from outside the container.
  • Microcks is the large option: a web UI that imports OpenAPI documents and Postman collections, then serves them as mocks and runs contract tests. A full install needs MongoDB and Keycloak, plus Kafka for its async features. The all in one microcks-uber image bundles an in memory MongoDB, which the project documents as suited to ephemeral use, so treat anything created in that UI as disposable and keep the source artifacts in git.

Which self-hosted API test runner should you run?

The job here is a sequence: authenticate, create an order, read it back, assert the state changed. That needs a value captured from one response and used in the next request. A tool that cannot carry state between calls is a health check, not an API test.

  • Hurl runs plain text files of HTTP requests from a single binary. A [Captures] section pulls values out of a response, an [Asserts] section checks them, and --test turns it into a test runner with a summary and an exit code. Version 8.0.1 is current as of August 2026.
  • Bruno CLI runs a folder of .bru files. Install with npm install -g @usebruno/cli, then run bru run folder --env Local --reporter-junit results.xml. The collection format is text files in a directory by design, so the diffs are readable in review.
  • Newman runs Postman collections outside Postman: npm install -g newman, then newman run collection.json -r cli,junit --reporter-junit-export results.xml. The catch is the format. The collection is one exported JSON blob, so the editing happens in Postman and the file in git is a copy that goes stale.
  • Schemathesis is a different kind of check. It reads an OpenAPI schema and generates cases that try to produce responses your schema says are impossible: uvx schemathesis run https://your.api/openapi.json. It finds crashes and contract violations, and it knows nothing about your business rules, so it sits beside a scripted suite rather than replacing it.
  • Hoppscotch self hosted is the web UI option, and it requires a Postgres instance. Understand that tradeoff before you install it: collections live in a database, not in your repository.

One to avoid. Step CI still appears in tool roundups and its YAML workflow format reads well, but the repository last received a commit in August 2024. A program that sits between your CI and your API is a poor place for unmaintained code.

Put the mock server behind the firewall

The setup below runs WireMock as a stand in for a payment provider. If the compose file format is new to you, Docker Compose on a VPS covers the lifecycle commands this section assumes.

services:
  mock-payments:
    image: wiremock/wiremock:3.13.2
    command: ["--verbose"]
    volumes:
      - ./mocks/payments:/home/wiremock
    ports:
      - "127.0.0.1:8080:8080"
    restart: unless-stopped

The 127.0.0.1: prefix on the port is the important part. A bare 8080:8080 publishes the mock on every interface including your public IP, and it stays reachable even with ufw denying that port, because Docker writes its own rules into the DOCKER iptables chain and those are evaluated before ufw's INPUT rules. Bind to the loopback address instead, or to a private interface address, and the kernel never accepts the connection from outside.

Your service under test then points at the mock. When the service runs in the same compose project, the mock's base URL is http://mock-payments:8080, because compose resolves service names on its own network. When the service runs on the host, it is http://127.0.0.1:8080. Set that through an environment variable, never in code, or the test URL ships to production.

Stubs go in ./mocks/payments/mappings/, one JSON file each.

{
  "request": {
    "method": "POST",
    "urlPath": "/v1/charges",
    "bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
  },
  "response": {
    "status": 201,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
  }
}

Start it, then check what actually loaded.

docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappings

--wait blocks until the container reports healthy, which works because the WireMock image ships a HEALTHCHECK against its /__admin/health endpoint. The mappings call lists every stub the server read. A stub you wrote that is missing from that list was never loaded: check the file sits under mappings/ rather than in the mounted root, and check the JSON parses.

When a request arrives and no stub matches, WireMock answers 404 with a body beginning Request was not matched, followed by a diff against the closest stub it holds. Read that diff before changing anything, because it names the exact field that differs. It is usually a path with /v1/charge where the stub says /v1/charges.

Write the test as a sequence with state carried between calls

Hurl files are plain text. Install the deb from the project's releases.

VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb

A suite that exercises your own API against the mock lives in tests/checkout.hurl.

POST {{base_url}}/orders
Content-Type: application/json
{
  "sku": "ssd-1tb",
  "amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"

GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"

The [Captures] block is what makes this an API test rather than two unrelated requests. order_id is read out of the first response and interpolated into the URL of the second. The assertion on charge_id is the point of the whole exercise: it proves your service called the payment provider and stored what came back, and the value it compares against is the one you wrote into the WireMock stub. One file now covers both halves of the flow.

hurl --test --variable base_url=http://127.0.0.1:3000 \
  --report-junit reports/junit.xml \
  --report-json reports/json \
  tests/

A passing run prints one line per file and a summary.

tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files:    1
Executed requests: 2 (30.1/s)
Succeeded files:   1 (100.0%)
Failed files:      0 (0.0%)
Duration:          64 ms

A failure prints error: Assert failure with the file and the line number, then the value it got against the value it expected, and hurl exits non zero so CI stops. If status reads pending where you expected paid, your service did not process the mock's response. The next thing to read is the WireMock request journal at /__admin/requests, which shows whether the call reached the mock at all.

Trigger the suite from your own CI runner

With a runner registered on the same box, the workflow is short. The runner is a plain process on the host, so docker and hurl must be installed on that host. Nothing is inherited from a hosted image.

name: api-tests
on: [push]
jobs:
  hurl:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Start the mock
        run: docker compose up -d --wait mock-payments
      - name: Run the suite
        run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
      - name: Archive the reports
        if: always()
        run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
      - name: Stop the mock
        if: always()
        run: docker compose down

if: always() on the archive step matters. Without it a failed test run skips the copy, so you lose exactly the report you wanted to read. The copy also has to land outside the workspace, because the runner cleans the workspace before the next job and the reports go with it.

Keep the results, not just the last run

A JUnit XML file per commit answers one question: did it pass. It does not answer when an endpoint started getting slower, because nothing reads those files once you stop opening them. For a trend, append one row per run to a small database on the same box. A single table holding the commit SHA, the file name, the pass count, the fail count and the duration is enough, and SQLite in production on a VPS is a reasonable place to put it: one file, no server process, and the whole history rides along in the backup you already take. Parse Hurl's --report-json output rather than the JUnit XML, since it is the machine readable format of the two.

What has to survive a container rebuild

Mock definitions and test suites are source code. They belong in a repository beside the service they describe, changed in the same pull request that changes an endpoint. A stub edited in a web UI, or an expectation pushed to MockServer over its REST API at runtime, exists only in that container's memory or that tool's database. Run docker compose down and it is gone, and nobody notices until a test starts passing for the wrong reason. If your repositories run on your own hardware as well, a self-hosted git server keeps the fixtures and the service inside one trust boundary.

Then the practical rules. Pin image tags, because latest can change how your mock matches requests with no change in your repository, and that failure is very hard to connect back to its cause. Mount stub directories read only when the tool does not need to write to them. Never put a mock's stubs in a named Docker volume, because the volume then becomes the source of truth and the copy in git silently becomes wrong.

One more, and it catches people. If you build stubs by recording real traffic through a proxy, read every generated file before you commit it. A recording holds exactly what the upstream sent back, including bearer tokens and customer email addresses. Committing it puts that in your repository permanently, because git keeps deleted content in history.

FAQ

What is the difference between an API mock server and an API test runner?

A mock server answers requests. It stands in for a dependency you cannot call from CI, and it never reports pass or fail. An API test runner sends requests to your own service, asserts on the responses, carries values from one call into the next, and exits non zero when an assertion fails. They solve different problems, and a typical setup runs both at once: the runner calls your service while your service calls the mock.

Can I test an internal API from a hosted CI runner?

Not without exposing it. A hosted runner sits outside your network, so it cannot reach a service bound to a private address. Your choices are publishing the API, running a tunnel, or maintaining a public staging copy, and each one adds a system that can fail or leak. A runner on the same private network calls the service directly, which is the main practical reason teams self host this work.

Where should mock stubs and API test suites live?

In git, beside the service they describe. Tools that store definitions as files, such as WireMock's mappings/ directory, Mockoon's data file, Hurl files and Bruno's .bru folder, give you code review and a container rebuild that costs nothing. Tools that store definitions in a database or a web UI need a backup plan and an export step, and the export is the part people forget until the container is already gone.

Why does my mock return 404 when the stub looks correct?

WireMock serves a stub only on an exact match. An unmatched request gets 404 with a body beginning Request was not matched, followed by a diff against the closest stub, and that diff names the field that differs. Common causes are a trailing slash on the path, a Content-Type header the stub requires that your client did not send, urlPath used where the stub needs urlPathPattern for a variable segment, and a body matcher that does not fit the payload. Check /__admin/requests first to confirm the request reached the mock at all.

Do I still need mocks if I have a staging environment?

Yes, for two reasons. A staging copy of an upstream you do not control still goes down and still rate limits you, so your suite fails for reasons that have nothing to do with your code. It also cannot produce the responses you most need to test, such as a declined card or a gateway timeout. A mock returns those on demand at local network speed, which turns a suite that takes minutes against a sandbox into one that takes seconds. Keep staging for the final check before release and use mocks in CI.