What Nx adds to Go and who it is for

Nx does not replace the Go toolchain. It sits one level above it and decides which tasks to run, in what order, and whether a previous result can be reused. The actual work still happens in go build, go test and go vet. Go modules still resolve dependencies, and Go's own build and test caches still skip recompiling unchanged packages. Each Nx target is a thin wrapper that shells out to go, and Nx handles orchestration around it.

That split matters because the two caches work at different levels. Go's cache works per package, inside a single invocation. Nx's cache works per task: it hashes a project's declared inputs and, on a match, restores the recorded outputs and terminal log without running go at all. On a CI runner with a cold local Go cache, that difference is significant if Nx's cache is shared across machines.

The main reason to adopt this setup is the polyglot monorepo. When Go services sit beside TypeScript frontends, shared schemas or other stacks, you want one task graph, one way to ask "what did this change affect?", and one caching layer for everything. The community nx-go plugin and hand-written run-commands targets both exist to bring Go into that graph.

Who benefits, and who doesn't

Platform teams running mixed-language repositories get the most value, since CI logic, caching and affected detection stop being per-language problems.

A Go-only repository usually gains little. go build ./... and go test ./... already understand the package graph and cache aggressively. Adding Nx there brings another config surface and little else.

Modeling Go code as Nx projects and choosing a module layout

Treat each deployable service and each shared package as its own Nx project. Each one gets a project.json whose build, test, lint and run targets use the nx:run-commands executor to call the go tool, for example go build -o dist/apps/orders ./apps/orders, scoped by cwd or by package path. These targets do no Go-specific work. Their value is that Nx can now name, schedule and cache each unit separately.

The module layout determines how much the Nx project graph can see.

Concern Single root go.mod Multiple modules + go.work
Project boundaries Directory convention only; any package can import any other Enforced by module paths and require directives
Rebuild trigger Any change to go.mod/go.sum touches every project Only modules that declare the changed dependency
Dependency bump blast radius Whole repository at once Per module, but shared libraries still fan out
Version alignment cost Low: one version of each dependency Higher: drift between modules needs a check or a bump script

A single module is simpler. It also makes the root go.mod an input to every project, so many routine changes invalidate most of the cache. Multiple modules give you narrower invalidation. The cost is keeping versions aligned across modules.

A community nx-go plugin is worth adding once you have more than a few projects. Its generators scaffold consistent projects, its executors replace hand-written commands, and it reads Go imports to infer dependency edges, which otherwise you would declare manually. The plugin is maintained outside the core Nx team, so pin its version. After each upgrade, run nx graph to confirm the inferred edges match your actual imports.

{
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "name": "orders",
  "projectType": "application",
  "sourceRoot": "apps/orders",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["{projectRoot}/**/*.go", "{workspaceRoot}/go.mod", "{workspaceRoot}/go.sum"],
      "outputs": ["{workspaceRoot}/dist/apps/orders"],
      "options": {
        "command": "go build -o dist/apps/orders ./apps/orders"
      }
    },
    "test": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["{projectRoot}/**/*.go", "{workspaceRoot}/go.mod", "{workspaceRoot}/go.sum"],
      "options": {
        "command": "go test ./apps/orders/..."
      }
    },
    "lint": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["{projectRoot}/**/*.go", "{workspaceRoot}/go.mod", "{workspaceRoot}/go.sum"],
      "options": {
        "command": "go vet ./apps/orders/..."
      }
    },
    "run": {
      "executor": "nx:run-commands",
      "options": {
        "command": "go run ./apps/orders"
      }
    }
  }
}

A project.json for an orders service in a single root go.mod layout, wrapping go build, test, vet and run with nx:run-commands; the root go.mod and go.sum are inputs, so editing them invalidates every cached target.

How the project graph, cache inputs and outputs work

Nx does not read Go import statements. Every edge in the project graph has to come from one of three places: the plugin's inference, implicitDependencies in project.json, or a dependsOn entry. Note that dependsOn orders tasks; only the first two create graph edges that nx affected can see. A missing edge fails without any error. If payments imports internal/auth and Nx has no edge between them, a change to auth marks only auth as affected. CI then skips the payments rebuild and ships the old binary, and every check stays green.

Inputs determine the cache key. Define a production named input that includes {projectRoot}/**/*.go, the module's go.mod and go.sum, go.work if you use workspaces, and every file pulled in through //go:embed. Nx cannot see embed directives, so list those asset globs explicitly. Exclude **/*_test.go from production and add it back in the input set used by test. With that split, editing a test reruns tests but does not rebuild the binary.

Outputs must be deterministic and declared, for example {workspaceRoot}/dist/{projectRoot}. Nx restores only the paths it was told about. If go build -o writes somewhere else, a cache hit replays the logs and exits successfully, but no binary appears on the CI runner.

Both extremes cost you:

  • Under-specified inputs produce stale cache hits, which ship wrong binaries.
  • Over-specified inputs, such as {workspaceRoot}/**/*, change the cache key on every commit. Everything rebuilds and the cache provides no benefit.
// apps/payments/project.json (Nx accepts comments in project.json)
{
  "name": "payments",
  "projectType": "application",
  // Nx does not parse Go imports, so declare the edge to internal/auth explicitly
  "implicitDependencies": ["auth"],
  "namedInputs": {
    "production": [
      "{projectRoot}/**/*.go",
      "!{projectRoot}/**/*_test.go",
      "{projectRoot}/go.mod",
      "{projectRoot}/go.sum",
      "{workspaceRoot}/go.work",
      // Files loaded via //go:embed are invisible to Nx; list them explicitly
      "{projectRoot}/templates/**/*"
    ],
    // Test hash = production inputs plus test files
    "goTest": ["production", "{projectRoot}/**/*_test.go"]
  },
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      // ^production: a change in a dependency's sources changes this hash
      "inputs": ["production", "^production"],
      // Nx restores only this path on a cache hit; -o must write inside it
      "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
      "options": {
        "cwd": "apps/payments",
        "command": "go build -o ../../dist/apps/payments/payments ./cmd/payments"
      }
    },
    "test": {
      "executor": "nx:run-commands",
      "inputs": ["goTest", "^production"],
      "options": {
        "cwd": "apps/payments",
        "command": "go test ./..."
      }
    }
  }
}

A project.json for a Go service that declares its dependency edge, splits build and test inputs, lists go:embed assets, and declares the output path that go build writes to.

Making Go builds reproducible enough to cache

Two caches are in play, and they stack. Nx hashes a task's declared inputs. On a match, it restores the outputs and skips the task. When the hash misses, go build or go test still runs, and Go's own build cache reuses compiled packages that did not change. Nx decides whether a task runs at all. Go makes the tasks that do run faster. Nx can only skip a task safely if its hash covers everything that affects the output.

Put the toolchain and target into the hash

Go source files are not the only inputs. The compiler version matters, so add a runtime input such as go version. A toolchain upgrade then invalidates old entries. Set GOOS, GOARCH and CGO_ENABLED explicitly and declare them as environment inputs. If they are left implicit, a macOS laptop and a Linux runner produce the same hash but different binaries. A remote cache would then restore a darwin binary into a Linux container.

Remove nondeterminism

Build with -trimpath so absolute checkout paths stay out of the binary. Do not stamp a timestamp or git SHA through -ldflags in the cached build. That value changes on every commit, so the build never hits the cache. Run stamping as a separate uncached step at release time.

Tests with side effects

Mark integration tests that touch databases or networks with cache: false. Run them with -count=1 so Go's test cache does not replay a stale pass.

Remote cache trust

A shared cache serves whatever artifact matches a hash. Let only CI on protected branches write to it, and give developer machines read-only access. Otherwise, one compromised or misconfigured laptop can poison artifacts that everyone else restores.

// apps/api/project.json (Nx parses comments in project.json)
{
  "name": "api",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "cache": true,
      // Hash covers sources, dependency sources, the toolchain, and the target platform.
      // CI must export GOOS, GOARCH and CGO_ENABLED explicitly before running Nx.
      "inputs": ["default", "^default", { "runtime": "go version" }, { "env": "GOOS" }, { "env": "GOARCH" }, { "env": "CGO_ENABLED" }],
      "outputs": ["{projectRoot}/dist"],
      "options": {
        "cwd": "{projectRoot}",
        // -trimpath keeps absolute checkout paths out of the binary; no version stamping here.
        "command": "go build -trimpath -o dist/api ./cmd/api"
      }
    },
    "test": {
      "executor": "nx:run-commands",
      "cache": true,
      "inputs": ["default", "^default", { "runtime": "go version" }],
      // Pure unit tests: on an Nx miss, Go's test cache still reuses unchanged package results.
      "options": { "cwd": "{projectRoot}", "command": "go test ./..." }
    },
    "integration-test": {
      "executor": "nx:run-commands",
      // Touches a database and the network, so neither Nx nor Go may replay a previous pass.
      "cache": false,
      "options": { "cwd": "{projectRoot}", "command": "go test -count=1 -tags=integration ./..." }
    },
    "release-build": {
      "executor": "nx:run-commands",
      // Stamping changes on every commit, so it lives in an uncached step run only at release time.
      "cache": false,
      "options": {
        "cwd": "{projectRoot}",
        "command": "go build -trimpath -ldflags \"-X main.version=$(git rev-parse --short HEAD)\" -o dist/api-release ./cmd/api"
      }
    }
  }
}

An Nx project.json for a Go service that puts the toolchain version and target platform into the build hash, builds with -trimpath, runs integration tests uncached with -count=1, and moves git SHA stamping into a separate uncached release target.

Wiring nx affected into CI

On pull requests, run nx affected -t build test lint --base=<base> --head=HEAD, where the base is the merge base with the target branch (git merge-base origin/main HEAD), not the tip of main. Comparing against the tip pulls in other people's merged changes and runs projects your branch never touched. On main itself, use the SHA of the last successful pipeline run as the base, so a failed run's changes are retried rather than skipped. Nx also reads NX_BASE and NX_HEAD, which keeps the command identical across jobs.

Most CI systems clone shallowly by default. When the base commit is not in the local history, Nx cannot compute the diff. Fetch full history, or at minimum fetch the target branch with enough depth to reach the merge base.

Nx only knows that Go files belong to a project. It does not understand module semantics, so a dependency bump in the root go.mod, go.sum or go.work will not mark every service as affected unless you add those files explicitly. Put them in sharedGlobals (or a shared named input) in nx.json so they feed every Go project's hash.

Persist GOCACHE and GOMODCACHE (locate them with go env) between runs alongside the Nx cache. A Nx cache miss still runs go build, and a warm Go cache keeps that rebuild incremental instead of cold.

Before relying on affected runs, check nx graph --affected on a few real PRs. Force-pushes can rewrite or delete a recorded base SHA, and a base that is too recent hides changes. Both failures are silent: fewer projects run, and the pipeline still goes green.

#!/usr/bin/env bash
set -euo pipefail
TARGET_BRANCH="${TARGET_BRANCH:-main}"

# Shallow clones may not contain the base commit, so Nx cannot diff against it.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow --no-tags origin
fi
# Make sure the remote-tracking ref for the target branch exists locally.
git fetch --no-tags origin "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"

if [ "${IS_PULL_REQUEST:-false}" = "true" ]; then
  # PRs: diff against the merge base, not the tip of main.
  NX_BASE="$(git merge-base "origin/${TARGET_BRANCH}" HEAD)"
else
  # main: last successful pipeline SHA (placeholder, supplied by your CI),
  # so changes from a failed run are retried instead of skipped.
  NX_BASE="${LAST_SUCCESSFUL_SHA:?set to the SHA of the last green main run}"
fi

# A force-push can leave a recorded base that no longer exists.
# Fail loudly instead of silently running too few projects.
git cat-file -e "${NX_BASE}^{commit}" \
  || { echo "base ${NX_BASE} not in local history" >&2; exit 1; }
export NX_BASE
export NX_HEAD=HEAD

# Paths to persist with your CI cache step, alongside the Nx cache directory.
echo "GOCACHE=$(go env GOCACHE)"
echo "GOMODCACHE=$(go env GOMODCACHE)"

# Log the affected set so reviewers can spot a suspiciously short list.
npx nx show projects --affected

# Nx reads NX_BASE and NX_HEAD, so the command is identical in every job.
npx nx affected -t build test lint

A CI script that fetches enough git history, picks the merge base for PRs or the last green SHA on main, fails if that base is missing, prints the Go cache paths to persist, and runs nx affected for build, test and lint.

Worked example: two services and a shared internal package

The repository has one go.mod at the root and three Nx projects: apps/orders, apps/billing, and libs/money. The libs/money package sits under an internal/ path so the Go toolchain itself rejects imports from outside the module. Each project.json defines build and test targets that run go build and go test against that project's own directory. Each target's inputs list the project's files plus a shared named input for {workspaceRoot}/go.mod and {workspaceRoot}/go.sum. The two service projects also declare "implicitDependencies": ["money"].

Why declare the edge by hand? Without a Go-aware plugin, Nx does not parse Go import statements. If the edge is missing, a change to libs/money would leave both services marked unaffected. CI would then pass on stale binaries. Declaring it explicitly is cheap, and you can check it in nx graph.

Change in PR Affected projects Reason
libs/money/*.go money, orders, billing Both services depend on money
apps/orders/*.go orders Nothing depends on orders
go.sum money, orders, billing Shared input for every project

The go.sum row is easy to miss. A dependency bump can change behaviour anywhere, so treating it as a global input is the correct, conservative choice.

Illustrative estimate. Assume a cold build and test takes 90 seconds per service and 30 seconds for money, and that cache hits are near-instant. Under those assumptions, an orders-only PR costs about 90 seconds instead of 210. A go.sum bump still costs the full 210 seconds.

Alternatives, when to avoid Nx, and impact on cost and engineering time

That full-rebuild case is the same under every option below. The options differ in how much other work they skip and in what it costs to keep that skipping correct.

Approach What skips work Ongoing cost Best fit
Plain go with Makefiles or scripts Go's local build cache and cached test results Low: one toolchain, no hash rules to maintain Go-only repos
Nx with run-commands Nx task cache and affected, based on inputs you declare Hand-written inputs and outputs that someone must audit Polyglot repos with a few Go projects
Nx with an nx-go plugin The same, plus a project graph inferred from Go imports Plugin upgrades that must keep pace with both Nx and Go Many Go projects alongside other languages

When to avoid Nx. In a Go-only repo, the Go toolchain already handles incremental work at the package level. Adding Nx mostly buys a shared remote cache, and the price is a second build system. Small teams should also avoid it if no one owns input correctness. A missing input produces a stale cache hit, which is worse than a slow build because it passes CI with the wrong artifact.

Illustrative estimate (assumed numbers, not a benchmark).

Monthly CI minutes saved = PRs × (full run − average affected run).

Assume 400 PRs per month, a 3.5-minute full run and a 2-minute average affected run:

400 × 1.5 = 600 minutes, or 10 hours.

That is machine time. Only the share engineers spend blocked waiting on CI is engineering time saved. Compare that share against the hours spent on plugin upgrades and input audits. If upkeep takes 6 hours a month, the benefit is marginal. If CI waits gate merges across several teams, the benefit is clear.

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 go monorepo, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.