How Nx build configurations work in project.json

A target in project.json is a named entry under targets, such as build, serve, or test. Each target has four parts:

  • executor: the code that does the work, for example a bundler or tsc wrapper.
  • options: the base settings that every run receives.
  • configurations: a map of named variants, typically development and production.
  • defaultConfiguration: the variant applied when the caller doesn't name one.

A minimal backend build target has an executor and base options for outputPath, main, tsConfig, and assets. Its production configuration turns off source maps and enables optimization. Its development configuration turns source maps back on. Configurations exist so that a single target expresses every environment, which keeps CI and local runs on the same entry point.

When a configuration runs, Nx merges its keys over the base options. The merge is shallow. If the base declares three assets entries and production declares one, the build gets exactly one. Nx doesn't append the entry or warn you, and the dropped files are usually noticed only when the deployed service can't find a template or migration file. The same rule applies to fileReplacements and any object-valued option. To avoid this, repeat the full array in the configuration, or keep variable lists out of the base options entirely.

Plugin-inferred targets may already define build with their own configurations. A hand-written target with the same name in project.json is layered on top of the inferred one, so check the merged result before you assume which values apply.

{
  "name": "orders-api",
  "targets": {
    "build": {
      "executor": "@nx/webpack:webpack",
      "defaultConfiguration": "production",
      // Base options: every configuration starts from these values
      "options": {
        "target": "node",
        "compiler": "tsc",
        "outputPath": "dist/apps/orders-api",
        "main": "apps/orders-api/src/main.ts",
        "tsConfig": "apps/orders-api/tsconfig.app.json",
        "assets": [
          "apps/orders-api/src/assets",
          "apps/orders-api/src/templates",
          "apps/orders-api/src/migrations"
        ]
      },
      "configurations": {
        "development": {
          "sourceMap": true
        },
        "production": {
          "optimization": true,
          "sourceMap": false,
          // Arrays replace the base value instead of merging with it.
          // Listing only "assets" here would drop templates and migrations,
          // so the full list is repeated.
          "assets": [
            "apps/orders-api/src/assets",
            "apps/orders-api/src/templates",
            "apps/orders-api/src/migrations"
          ]
        }
      }
    }
  }
}

A minimal backend build target in project.json (Nx accepts comments in this file) with base options, development and production configurations, and a production assets array repeated in full because configuration values replace base arrays rather than merging with them.

Selecting a configuration and what happens when one is missing

…you rely on a configuration name existing.

Passing a configuration

You select a configuration with --configuration or its short form -c. Both nx build api -c staging and nx run api:build --configuration=staging work, and nx run also accepts the compact project:target:configuration form, as in nx run api:build:staging. The compact form is useful in scripts and CI matrix jobs, where one string can identify the whole task.

Resolution order

Nx resolves options in a fixed order:

  1. An explicit configuration. If you pass -c staging, the staging block is merged over the target's base options.
  2. defaultConfiguration. If you pass nothing, Nx uses the configuration named in the target's defaultConfiguration field.
  3. Base options only. If neither applies, the target runs with options alone.

This order is why setting defaultConfiguration to production is a common choice. A bare nx build api on a developer laptop or in CI then produces production output, and anything else has to be requested explicitly.

When the name doesn't exist

If you request a configuration that the invoked target does not define, Nx stops with an error. It does not silently fall back, and that strictness is useful because a typo cannot quietly ship a production build.

Dependencies are handled differently. With dependsOn: ["^build"], Nx forwards the configuration name to upstream libraries. If a library lacks a staging block, Nx typically uses that library's own default instead of failing. Confirm the exact behavior on your Nx version. Either way, the result is a mixed build. Your service gets staging settings while its libraries get something else, and nothing warns you. To avoid this, define the same configuration names on every target in the dependency chain, even when some of those blocks are empty.

# Assumes apps/api/project.json defines a "build" target with base "options",
# configurations named "production" and "staging", and
# "defaultConfiguration": "production".

# 1. Explicit configuration: the "staging" block is merged over base options.
#    These three commands select the same task.
nx build api -c staging
nx run api:build --configuration=staging
nx run api:build:staging   # compact project:target:configuration form

# 2. No configuration passed: Nx uses defaultConfiguration ("production").
nx build api

# 3. If the target had no defaultConfiguration, the command above would
#    run with the base "options" only.

# A name the invoked target does not define fails instead of falling back,
# so a typo cannot quietly produce a different build.
nx build api -c stagng

# With dependsOn ["^build"], the name "staging" is also passed to upstream
# libraries. A library without a "staging" block may build with different
# settings and give no warning. Verify how your Nx version handles this,
# and define the same configuration names on every build target.
nx run-many -t build -c staging

This example shows three equivalent ways to select a configuration and how Nx resolves options when you pass an explicit name, rely on defaultConfiguration, or request a name the target doesn't define.

fileReplacements: swapping source files at build time

Configurations change option values, but sometimes the difference between environments lives in source code rather than in executor options. That is the job of fileReplacements. It tells the bundler to substitute one file for another while it resolves the module graph. Your code keeps importing a single path, and the build decides which file actually sits behind it.

The usual pattern is an environment.ts file that exports a plain object with development defaults, such as a local API base URL and verbose logging. Next to it sits environment.prod.ts, which has the same shape but production values. In the production configuration of the build target, you add a fileReplacements entry. Its replace field points at environment.ts and its with field points at environment.prod.ts. Application code still imports ./environments/environment, and the substitution happens only when that configuration is selected. Keep both files exporting identical types so a missing key fails type-checking instead of surfacing at runtime.

Support depends on the executor. fileReplacements is a bundler feature, so the webpack- and esbuild-based executors may accept it. A build that runs plain tsc compiles files in place and may have no such option. Check the executor's schema before relying on it. If the option is unsupported, it is either rejected or does nothing.

Treat every replaced value as public. The swapped file is compiled into the output. That output is stored in CI artifacts and remote caches, and anyone holding it can read the values. Nothing placed here is a secret.

{
  "name": "orders-api",
  "targets": {
    "build": {
      "executor": "@nx/esbuild:esbuild",
      "options": {
        "main": "apps/orders-api/src/main.ts",
        "outputPath": "dist/apps/orders-api",
        "tsConfig": "apps/orders-api/tsconfig.app.json",
        "platform": "node"
      },
      "configurations": {
        "development": {},
        "production": {
          "fileReplacements": [
            {
              "replace": "apps/orders-api/src/environments/environment.ts",
              "with": "apps/orders-api/src/environments/environment.prod.ts"
            }
          ]
        }
      },
      "defaultConfiguration": "production"
    }
  }
}

A project.json build target whose production configuration swaps environment.ts for environment.prod.ts at bundle time. Confirm fileReplacements is in your executor's schema, and keep secrets out of both files.

Build-time vs runtime configuration: when to use each and when to avoid configurations

The rule follows from that: a build configuration should change the code that is produced, not the values the code reads. Minification, source maps, feature flags you want tree-shaken out, and the target platform all belong in build configurations, because they alter the output bundle. Database URLs, credentials, hostnames, and anything else that differs per deployment belong at runtime.

The reason is artifact promotion. If staging bakes the staging database host into the bundle, that artifact can never run in production. Shipping requires a second build with production, and that second build is a different artifact from the one your integration tests ran against. A dependency resolution change or a nondeterministic step can produce a difference, and nothing in the pipeline will catch it. Building once and deploying the same bytes everywhere keeps the guarantee that what was tested is what ships.

Approach Resolved at Failure surfaces at Same artifact across environments? Use for
Per-environment configurations Build Build No Optimization, source maps, platform target
fileReplacements Build Build No Compile-time feature flags, non-secret constants
Env vars validated at startup Process start Startup Yes URLs, credentials, hostnames
Mounted config files Process start Startup Yes Larger structured config, secrets from the orchestrator

Runtime configuration has a cost. A missing variable no longer breaks the build. It breaks the process when it starts. So validate the whole configuration at boot and exit non-zero on the first error. This keeps a misconfigured deploy from passing its health check and serving traffic.

 BAKED-IN: one build per environment (avoid for per-deploy values)

 +--------+ -c staging    +-----------------+    +-------------+
 | source |-------------->| bundle A        |--->| staging     |
 |        |               | DB=staging-host |    | tests pass  |
 |        | -c production +-----------------+    +-------------+
 |        |-------------->| bundle B        |--->| production  |
 +--------+               | DB=prod-host    |    | never tested|
                          +-----------------+    +-------------+
  tested artifact (A) is not the shipped artifact (B)

 BUILD ONCE, CONFIGURE AT RUNTIME

 +--------+ -c production +---------------------------+
 | source |-------------->| bundle: one set of bytes  |
 +--------+ minify, maps, +---------------------------+
            flags, target        |                   |
                                 |  promote          |
                                 v                   v
                          +--------------+    +--------------+
                          | staging      |    | production   |
                          | validate env |    | validate env |
                          | at boot      |    | at boot      |
                          +--------------+    +--------------+
                                 ^                   ^
                          +--------------+    +--------------+
                          | staging vars |    | prod vars    |
                          | / mounted    |    | / mounted    |
                          +--------------+    +--------------+
 missing/invalid config: exit non-zero at startup, never serves traffic

Baking per-environment values into separate builds means production runs an artifact that was never tested, while building once and injecting validated runtime config promotes the same bytes from staging to production.

Shared defaults with targetDefaults and inspecting the merged result

If a dozen services each repeat the same production configuration, they will drift: one gets source maps disabled, another keeps an old optimization flag. targetDefaults in nx.json fixes this by defining options and configurations once, keyed by target name or executor. Every project that declares that target inherits them, and values in a project's own project.json take precedence. Put the settings every service must share in targetDefaults, such as output paths following a common pattern, production optimizations and the default configuration name. Leave only service-specific details, such as the entry point or a fileReplacements list, in each project.

When a project-level override is justified

Override a default only when the service really differs from the rest. Examples include a worker that needs a different bundling target, or a legacy service that cannot yet drop a flag. Add a comment or commit message explaining why. Without one, the next person will either copy the override elsewhere or delete it.

The cost: hidden behavior

The trade-off is readability. A single project.json no longer describes how that project builds, because part of its behavior lives in nx.json. Nx plugins can also infer targets from tool config files with no project.json entry at all. That gives three layers that merge into one result.

To see that result, run nx show project <name>. It prints the effective merged configuration for the project. Add --web to open an interactive view that shows which file or plugin each target and value came from. Check this view before editing defaults and whenever a build behaves differently from what its project.json suggests.

Caching and CI: inputs, env variables and affected builds

Nx identifies a task by project, target and configuration, so api:build:production and api:build:development have separate cache entries. Each also gets its own hash from its inputs. Switching configurations never reuses the other build's output, and a cached production artifact is reused only when everything that feeds it is unchanged.

That guarantee depends on declaring inputs correctly. namedInputs in nx.json defines reusable file sets, such as a production set that excludes spec files. Each target's inputs lists what affects its hash. Files are tracked automatically once they match an input pattern, but environment variables are not. If your build reads API_BASE_URL or a feature flag through a bundler define or a generated constants file, add it as an { "env": "API_BASE_URL" } input. Otherwise, changing the variable leaves the hash unchanged, and Nx restores an artifact built with the old value. The build log will report success and the files will look correct, so this kind of stale cache hit can take hours to trace.

In CI, run nx affected -t build --configuration=production so that only changed projects and their dependents rebuild. Use the same configuration names across libraries and applications, so that when dependsOn pulls in upstream builds, they resolve to the matching configuration instead of falling back to their defaults. Add a pipeline step that checks every deployable project defines the configuration you pass, so a missing name stops the job before anything ships.

Build success says nothing about runtime configuration. Have each service validate its required settings at startup and exit non-zero if any are missing or malformed. Then add a smoke test that starts the built artifact with representative environment values and calls a health endpoint. This catches errors that compilation cannot.

Those checks pay off most when every deployment runs the same artifact. That points to a small layout for each service:

  • A build target with exactly two configurations, development and production. They differ only in how the code is compiled (optimization, source maps), not in where it will run.
  • A serve target whose configurations point at those builds through buildTarget, for example api:build:development. Local runs then use the same compile settings as the build.
  • A runtime config module that reads process.env once at startup, validates it and exports typed values. Staging, QA and each production region differ only in the environment values they inject.

The anti-pattern adds a build configuration for every deployment environment: staging, qa, prod-eu, prod-us.

Aspect Per-environment configurations Two configurations + runtime config
Builds per change One per environment One production build
Cache entries Multiplied by environment count One per input hash
What reaches production A binary QA never tested The artifact QA tested
Adding a region Edit project.json, rebuild, retest Add environment values

Illustrative estimate (assumed numbers, not a benchmark)

CI minutes per day = affected services × configurations built × minutes per build × merges per day.

Assume 6 affected services, 4 minutes per build and 15 merges per day:

  • Four environment configurations: 6 × 4 × 4 × 15 = 1,440 minutes.
  • One promoted production build: 6 × 1 × 4 × 15 = 360 minutes.

The savings in debugging time are harder to put a number on, but they come from one fact: with a single artifact, a bug that shows up only in production cannot be caused by a build difference. Investigation can go straight to the environment values and the data.

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 build configurations, 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.