How does Nx decide whether an integration test result can be reused?

Before running a target, Nx computes a hash from everything the target declares as inputs. If that hash matches a previous run, locally or in a remote cache, Nx replays the stored terminal output and restores the declared outputs instead of executing the task. For a test target, a cache hit means the stored pass or fail is reported again without the tests running.

The hash is built from several kinds of inputs:

  • Project files matched by the target's inputs, usually through a named input such as default or production.
  • Named inputs defined in nx.json. These are reusable file sets that you can exclude from or add to, for example to leave test specs out of a production hash.
  • Dependency inputs and outputs. The ^ prefix pulls in the matching inputs of projects this one depends on, and dependentTasksOutputFiles hashes the build artifacts of upstream tasks.
  • Runtime inputs. These are commands whose output is hashed, such as node -v or docker --version.
  • Env inputs. These are named environment variables whose values become part of the hash.

Nx can only hash what it can see. Testcontainers pulls important state from outside the workspace:

  • Image tags. The string postgres:16 in your source is hashed. The image digest that tag resolves to on a given day is not. A re-pushed tag changes the server under test without changing the hash.
  • Server-side defaults that ship with the image, such as collation, time zone and configuration settings. Your queries depend on them, but they appear nowhere in your files.
  • Schemas or migrations kept in another repository, fetched at test time.

This leads to the core problem. When an input that affects the result is missing from the hash, Nx replays an earlier pass for code that would fail today. The result is a false green that looks the same as a real one.

 INSIDE THE HASH (declared inputs)     OUTSIDE THE HASH (Testcontainers)
+-------------------------------+      +-------------------------------+
| project files via named       |      | image digest behind a tag     |
|   inputs (default, production)|      |   postgres:16 -> ? digest     |
| ^ inputs of dependencies      |      | server defaults: collation,   |
| dependentTasksOutputFiles     |      |   time zone, config settings  |
| runtime: node -v,             |      | schemas / migrations fetched  |
|   docker --version            |      |   from another repository     |
| env: named variables          |      |                               |
+---------------+---------------+      +---------------+---------------+
                |                                      |
                v                                      | not hashed;
       +-----------------+                             | only affects
       |    task hash    |                             | a real run
       +--------+--------+                             |
                |                                      |
                v                                      |
       +-----------------+                             |
       | local / remote  |                             |
       |  cache lookup   |                             |
       +---+---------+---+                             |
       hit |         | miss                            v
           |         +-------------------> +-----------------------+
           v                               | run tests against the |
  +----------------------------+           | current container      |
  | replay stored output:      |           +-----------------------+
  | earlier PASS reported even |
  | if a real run would FAIL   |
  +----------------------------+

Nx hashes only declared inputs, so a change in image digest, server defaults or external schemas still produces a cache hit that replays an earlier pass instead of running the tests.

Who should cache Testcontainers tests, and when should you avoid it?

Caching pays off when the arithmetic is lopsided. A backend or platform team with dozens of services, each running a container-backed suite that spends most of its time pulling images, starting Postgres or Kafka, and running migrations, rebuilds the same results on every pull request. If only a few projects change per commit, replaying the rest from cache removes most of that wait. These teams usually own their Dockerfiles, lockfiles and migration scripts, so they can list what a suite actually depends on.

Avoid caching, or limit it, when the result depends on things the hash cannot see:

  • External services. A suite that calls a shared staging API, a third-party sandbox, or a cloud emulator controlled by someone else can break without any change in your repository.
  • Mutable image tags. postgres:latest or a floating major tag can point to a different image tomorrow. The tag string in your config stays the same, so the hash stays the same too.
  • Time-dependent data. Fixtures with expiring tokens, date-relative queries, or seeded data fetched at runtime change behavior without changing any input.
  • Inputs you can't enumerate. If nobody can say which files and environment variables affect the outcome, you can't describe them to Nx.

The sensible default is selective caching. Mark a suite cacheable only after you have pinned its images by digest and declared its inputs. Leave everything else uncached. More thorough inputs make a false green less likely, but they can't rule it out. The Docker daemon, kernel and host behavior are never in the hash. That leftover risk is why the scheduled uncached runs covered later still matter.

Setting up a separate integration target with explicit inputs

Start by giving integration tests their own target, separate from test. Unit tests depend only on source and test files, so they can hit the cache on nearly every run. If they share a target with container-backed tests, they also pay for Docker startup and inherit the broader input set, so their hit rate falls with everything else.

Next, define an integration named input in nx.json and reference it from the target's inputs. It should cover:

  • project source and test files under {projectRoot}
  • migrations, schema files and seed fixtures
  • the same input from dependencies, using the ^ prefix, so a change in a shared data-access library invalidates its consumers
  • the environment variables that change behavior, such as a database flavor or a feature flag
  • the checked-in file that pins container images

Exclude Markdown, docs and config that the tests never read, such as lint or editor settings.

The image file matters because Nx hashes files, not registries. If the file pins postgres by digest or exact tag, a bump is a file diff, so the hash changes and the tests rerun. With latest, the registry can publish a different image while every hashed byte stays the same. Nx then replays a result produced against an image you no longer run.

Don't overcorrect, though. Each input you add is another way to miss the cache. Adding the workspace root, every lockfile or all of {workspaceRoot}/** pushes the hit rate toward zero. You keep the cost of caching and lose its benefit. Declare what the tests actually read and nothing more.

// nx.json (Nx parses this file as JSONC, so comments are allowed)
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    "integration": [
      // project source, tests and local fixtures
      "{projectRoot}/**/*",
      // schema, migrations and seed data shared across services
      "{workspaceRoot}/db/migrations/**/*",
      "{workspaceRoot}/db/schema/**/*",
      "{workspaceRoot}/db/seeds/**/*",
      // pinned image refs (e.g. postgres:16.4 or postgres@sha256:...)
      // a bump is a diff here, so the hash changes and tests rerun
      "{workspaceRoot}/testing/container-images.json",
      // env vars that change test behavior
      { "env": "DB_FLAVOR" },
      { "env": "FEATURE_NEW_BILLING" },
      // files the tests never read: excluded so they cannot cause misses
      "!{projectRoot}/**/*.md",
      "!{projectRoot}/docs/**/*",
      "!{projectRoot}/eslint.config.*",
      "!{projectRoot}/.eslintrc.json"
    ]
  },
  "targetDefaults": {
    // unit tests: narrow inputs, no Docker, high hit rate
    "test": { "cache": true, "inputs": ["default", "^production"] },
    // container-backed tests: own target, own inputs;
    // ^integration pulls the same input from dependency libraries
    "integration": { "cache": true, "inputs": ["integration", "^integration"] }
  }
}

An nx.json that gives container-backed tests their own cached target, with a reusable 'integration' named input covering source, migrations, fixtures, env vars, the pinned-image file, and dependency inputs, and that excludes docs and lint config.

How do you run Docker in CI for Testcontainers?

Testcontainers does not care where Docker lives. It only needs a daemon it can reach, either through DOCKER_HOST or the default local socket. How hard that is depends on what the CI job itself runs in.

VM-based runners that ship with Docker are the simple case. The job and the daemon share a machine, containers are reachable on localhost, and the runner is discarded afterwards, so test containers cannot leak into later jobs.

Containerized runners, where each job runs inside a container, have no daemon of their own. You have two options:

  • Mounting the host socket gives the job control of the host's Docker daemon. Any code in the job, including a compromised dependency in a test suite, can then start privileged containers, mount host paths or inspect other jobs' containers. This is effectively root on the host.
  • Docker-in-Docker (DinD) runs a separate daemon per job. It isolates better, but it usually requires privileged mode, which still weakens the boundary. Each daemon also starts with an empty image store, so every job pulls Postgres, Kafka and similar images again unless you add a registry mirror.
Approach Isolation Setup Image cache between jobs Speed
VM runner with Docker Strong (ephemeral VM) Minimal Depends on runner reuse Fast
Mounted host socket Weak (host-level access) Simple Shared with host Fast
Docker-in-Docker Moderate (needs privileged) More moving parts Lost unless mirrored Slower

If your runners are shared across teams or repositories, avoid the socket mount. Prefer ephemeral VM runners. Use DinD only where VM runners are not available.

How do nx affected, parallelism and hermetic tests work together?

nx affected and the cache answer different questions. affected compares your branch against a base commit and decides which projects are candidates to run. The cache then checks each candidate's computed hash and skips any task whose inputs match a stored result. A project can be affected and still be served from cache, or unaffected and never considered at all. Neither one tells you whether the test is correct. They only tell you whether its inputs changed.

Limit concurrency for the integration target

Each integration suite may start a database, a broker and a cache. If Nx runs many of these suites at once, a CI runner can run out of memory and kill containers partway through a suite. Pass a low --parallel value when you invoke the integration target, separate from the higher value you use for unit tests. Each container is a full process with its own memory footprint, so the right limit depends on runner size, not CPU count.

Avoid shared resources

Let Testcontainers assign random host ports and read them back at runtime. Don't set fixed container names. Both cause collisions when suites run concurrently on one Docker host.

Hermeticity rules for trustworthy cache hits

  • No calls to external services or shared staging databases.
  • Deterministic seed data, with no random fixtures that lack a fixed seed.
  • A fresh container or schema per suite.
  • No assertions that depend on wall-clock time, time zones or network latency.

Reusing containers across suites makes runs faster, but it weakens isolation. State left behind by one suite can hide ordering bugs that only appear when suites run alone or in a different order.

// Run with a low limit, e.g. `npx nx affected -t integration --parallel=2`,
// so only a couple of database containers exist on the runner at once.
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";

let container: StartedPostgreSqlContainer;
let client: Client;

beforeAll(async () => {
  // Start a fresh container for this suite. No .withName(), so concurrent suites can't collide on names.
  container = await new PostgreSqlContainer("postgres:16-alpine").start();

  // Testcontainers maps 5432 to a random free host port.
  // The connection URI already contains that port, so nothing is hardcoded.
  client = new Client({ connectionString: container.getConnectionUri() });
  await client.connect();

  // Deterministic seed: fixed ids and values, with no random fixtures.
  await client.query("CREATE TABLE orders (id int PRIMARY KEY, total_cents int NOT NULL)");
  await client.query("INSERT INTO orders (id, total_cents) VALUES (1, 1500), (2, 2500)");
}, 60_000);

afterAll(async () => {
  // Stop the container so no state leaks into other suites.
  await client?.end();
  await container?.stop();
});

it("sums order totals", async () => {
  const { rows } = await client.query("SELECT SUM(total_cents)::int AS total FROM orders");
  // Assert only on seeded data. Nothing depends on now(), time zones or network latency.
  expect(rows[0].total).toBe(4000);
});

A hermetic integration suite that starts its own Postgres container on a random mapped port, seeds fixed data and avoids time-dependent assertions, so its cached Nx result can be trusted when run with a low --parallel limit.

How should remote caching and uncached safety runs be configured?

A remote cache hit tells another machine that it may skip the run and replay a stored result. That result is only trustworthy if the target's inputs describe everything that could change it. For Testcontainers suites, some of that environment is hard to hash: the Docker engine version, the container runtime, the CPU architecture, and whether an image tag such as latest now resolves to a different digest. Pinning image digests and adding runtime details to inputs narrows the gap, but it does not close it.

Restrict who can write to the cache

Give the read-write cache credential only to CI runners, which share a known Docker setup. Developer machines should get read-only access, or no remote cache access for the integration target. This matters most when the team uses mixed setups. A green result recorded on an ARM laptop running a different container runtime should never satisfy a CI check on x86 Linux.

Schedule uncached runs

Add a nightly job, or a job on every merge to main, that runs the integration target with --skip-nx-cache. The flag forces execution and ignores stored results for that invocation. It leaves the cache: true setting in place for pull requests. This run catches drift the cache hides, such as a base image republished under the same tag or a dependency that the inputs miss.

The trade-off is extra CI minutes for a full run. You also get a delayed signal: drift surfaces hours after the change that caused it, not on the pull request. That delay is acceptable for main and nightly runs. It is not acceptable as your only correctness check.

# .github/workflows/integration-uncached.yml
name: integration-uncached

on:
  push:
    branches: [main]        # full run after every merge to main
  schedule:
    - cron: '0 3 * * *'     # nightly run catches image/tag drift with no code change

jobs:
  integration:
    runs-on: ubuntu-latest  # known x86 Linux + Docker setup, same as PR runners
    env:
      # Read-write cache token exists only as a CI secret.
      # Developer machines get a read-only token or none at all.
      NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CACHE_RW_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci

      # Record the environment the cache cannot hash, for debugging drift.
      - run: docker version && uname -m

      # --skip-nx-cache forces execution and ignores stored results for this
      # invocation only. 'cache: true' in nx.json still applies to PR runs.
      - run: npx nx run-many -t integration --skip-nx-cache

A GitHub Actions workflow that runs the integration target with --skip-nx-cache on every merge to main and nightly, while keeping the read-write cache token limited to CI.

What are the alternatives, and how does this affect CI cost and engineering time?

only pull-request gate.

Comparing the options

Approach CI time saved Upkeep False-green risk
Never cache None None None
Selective cache, explicit inputs High on unrelated changes Inputs and image digests Low, if safety runs exist
Cache everything, broad inputs Low, because most changes invalidate Low Low, but little benefit
nx affected only Moderate Accurate project graph Moderate: misses implicit dependencies

Most of the cost of selective caching is engineering time. Someone has to keep inputs accurate, and every bump to a pinned image digest invalidates the cache, whether a person or a bot opens that bump. The most expensive part is debugging a false green, because you first have to work out which missing input let a stale result through.

The time savings come from three places: suites that are skipped entirely, pinned images that are pre-pulled once and not on every run, and Docker layer caches reused across runs on CI runners that support them.

Ryuk is the Testcontainers sidecar that removes containers, networks and volumes after the test process exits, including when the process crashes. Some locked-down CI environments require you to disable it. If you do, record that choice and add your own cleanup step. Without one, orphaned containers on persistent runners can slowly fill the disk.

Illustrative estimate (assumed numbers, not a benchmark). Assume 200 runs per week, a 12-minute integration suite, and a 60% cache-hit rate. Assume false greens cost 3 hours per month to debug.

Saved minutes per week = runs × suite minutes × hit rate = 200 × 12 × 0.6 = 1,440 minutes, about 24 runner-hours. Compare that with input upkeep plus debugging time, which here is roughly 0.75 engineer-hours per week. Plug in your own numbers to see whether caching pays off.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx testcontainers ci, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.