What an Nx custom executor is and when it beats nx:run-commands

An Nx custom executor is a TypeScript function that Nx calls to run a target. It receives typed options and a context object describing the workspace and the current project, and it reports success or failure back to Nx. Its options are declared in a JSON schema, so Nx validates project.json values before your code runs. An Nx local plugin is a package inside your own workspace that holds executors (and often generators). Projects reference it by name, and it is never published to a registry.

This setup suits platform and backend teams that run many services with the same deploy shape: build an image, push it, apply a manifest, wait for rollout. The logic lives in one place, and each service only declares its options.

Concern Custom executor nx:run-commands Plain script
Typed options Schema plus TypeScript types Free-form command strings Ad hoc argument parsing
Validation Nx checks the schema before running None beyond shell errors Whatever you write
Reuse across services One implementation, per-project options Commands copied into each project Shared file, inconsistent invocation
Structured output Returns a success result to Nx Exit code only Exit code only
Local/CI parity Same nx run everywhere Same, but shell-dependent Depends on the environment
Debugging Attach a Node debugger to the function Read shell output Depends on the language

A plain script is still the better choice in three cases. The first is a single service, where the abstraction has nothing to reuse. The second is a one-off migration step. The third is deploy logic that is still changing weekly, because each change would also mean updating the schema and the executor's tests.

How a local plugin is laid out and scaffolded

A local plugin is an ordinary library in the workspace, usually under libs/ or tools/, that Nx treats as a plugin. It has four parts that Nx reads:

  • The implementation file. A TypeScript module whose default export is an async function. It receives the target's options and an executor context and returns a success result.
  • schema.json. A JSON Schema that describes the executor's options. Nx validates project.json options against it and applies its defaults before your code runs. Required fields and enums therefore fail fast, before any deploy logic starts.
  • executors.json. The manifest. It maps each executor name to an implementation path and a schema path.
  • The executors field in the plugin's package.json. This field points Nx at the manifest. Without it, Nx cannot find the plugin's executors.

The chain of lookups when a target runs:

project.json target
  "executor": "@acme/deploy:deploy"
        │
        ▼
@acme/deploy package.json ── "executors" ──► executors.json
                                               │
                         "deploy": { implementation, schema }
                               │                 │
                               ▼                 ▼
                   compiled deploy.js       schema.json

Scaffold the plugin with the Nx plugin generators instead of writing these files by hand. They create the files and the paths that connect them. Generator names and options have changed between Nx versions, so check the documentation for the version you run.

Note that implementation usually points at compiled JavaScript, not the TypeScript source. A change to the executor has no effect until the plugin is rebuilt. Stale build output is a common cause of an executor change that "didn't work," so rebuild the plugin before you start debugging the logic.

# Layout of a local plugin library (paths are illustrative)
libs/deploy/
├── package.json            # declares the plugin and points at the manifest
├── executors.json          # manifest: executor name -> implementation + schema
└── src/executors/deploy/
    ├── executor.ts         # default export: async (options, context) => ({ success })
    └── schema.json         # JSON Schema for options; Nx validates and applies defaults

# --- libs/deploy/package.json ---
{
  "name": "@acme/deploy",
  "version": "0.0.1",
  "main": "./src/index.js",
  "executors": "./executors.json"
}

# --- libs/deploy/executors.json ---
# No file extension on implementation: in the built output it resolves to executor.js.
{
  "executors": {
    "deploy": {
      "implementation": "./src/executors/deploy/executor",
      "schema": "./src/executors/deploy/schema.json",
      "description": "Deploy a backend service"
    }
  }
}

# --- apps/api/project.json (target excerpt) ---
# "@acme/deploy" matches package.json "name"; "deploy" matches the executors.json key.
{
  "targets": {
    "deploy": {
      "executor": "@acme/deploy:deploy",
      "options": { "environment": "staging" }
    }
  }
}

The files a target resolves through: project.json names @acme/deploy:deploy, the plugin's package.json "executors" field points to executors.json, and the "deploy" entry there points to the executor implementation and its schema.json.

Writing the deploy executor in TypeScript

The executor itself is a default-exported async function that takes two arguments: the resolved options and an ExecutorContext. It returns a promise of an object with a success boolean. Nx uses that boolean, not thrown exceptions, to decide whether the task failed, so catch errors inside the function and translate them into { success: false }.

Declare a TypeScript options interface with the same fields as schema.json: imageRepository, infraStack, migrationsDir, dryRun, and so on. Nx validates against the schema at runtime, and TypeScript checks the interface at compile time. Nothing ties the two together automatically, so they drift. To prevent that, generate the interface from the schema as a build step, or add a unit test that compares the schema's property keys to a typed fixture.

Resolve everything from the context. context.projectName identifies the service. context.root is the workspace root. The project's own root comes from the project configuration in the context, which also lets you read per-service metadata. Join these into Dockerfile and migration paths so that no service name or directory appears in plugin code.

Put environment differences in configurations.staging and configurations.production in each project's configuration. Nx merges the selected configuration into options before your function runs, so the executor never branches on environment names.

Run the container build and push, the infrastructure CLI, and the migration runner with spawn and argument arrays. Arrays avoid shell quoting bugs and injection through option values.

  • Use inherited or piped stdio so output streams live.
  • Map any non-zero exit code to success: false.
  • Before each step, log the full command and its arguments.
  • With dryRun, print the commands and skip execution.
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import type { ExecutorContext } from '@nx/devkit';

// Mirrors schema.json; a unit test asserts both declare the same keys.
export interface DeployExecutorOptions {
  imageRepository: string;  // e.g. registry.example.com/acme
  infraStack: string;       // set per env in configurations.staging / .production
  migrationsDir: string;    // relative to the project root
  migrateCommand: string[]; // argv for the migration runner, e.g. ["npx", "knex", "migrate:latest"]
  dryRun?: boolean;
}

function run(cmd: string, args: string[], cwd: string, dryRun: boolean): Promise<number> {
  // Log the exact argv and cwd so a failed step can be re-run by hand.
  console.log(`${dryRun ? '[dry-run] ' : ''}$ ${[cmd, ...args].map((a) => JSON.stringify(a)).join(' ')}  (cwd: ${cwd})`);
  if (dryRun) return Promise.resolve(0);
  return new Promise((resolve) => {
    const child = spawn(cmd, args, { cwd, stdio: 'inherit' }); // no shell: args are never re-parsed
    child.on('error', (err) => { console.error(err.message); resolve(1); });
    child.on('close', (code) => resolve(code ?? 1)); // killed by signal counts as failure
  });
}

export default async function deployExecutor(options: DeployExecutorOptions, context: ExecutorContext): Promise<{ success: boolean }> {
  const name = context.projectName!;
  const projectRoot = join(context.root, context.projectsConfigurations!.projects[name].root);
  const image = `${options.imageRepository}/${name}:${process.env.GIT_SHA ?? 'local'}`;
  const [migCmd, ...migArgs] = options.migrateCommand;
  const steps: Array<[string, string[], string]> = [
    ['docker', ['build', '-f', join(projectRoot, 'Dockerfile'), '-t', image, projectRoot], context.root],
    ['docker', ['push', image], context.root],
    ['pulumi', ['up', '--stack', options.infraStack, '--yes'], projectRoot],
    [migCmd, migArgs, join(projectRoot, options.migrationsDir)],
  ];
  for (const [cmd, args, cwd] of steps) {
    const code = await run(cmd, args, cwd, options.dryRun ?? false);
    if (code !== 0) { console.error(`${cmd} exited with code ${code}`); return { success: false }; }
  }
  return { success: true };
}

A deploy executor that resolves paths from the ExecutorContext, runs docker, the infrastructure CLI, and the migration runner with argument arrays and inherited stdio, logs each exact command, honors dryRun, and maps any non-zero exit to success: false.

Wiring deploys into the task graph without caching

Once the executor behaves correctly, the next job is making Nx run it at the right time. In the deploy target's project.json entry, set dependsOn to ["build"] so the project's own build finishes first. Add "test" or a "migrate" target to that list if a deploy must never ship untested code or run against an unmigrated schema. Use the ^build form only when the service also needs its dependencies' build outputs.

In CI, nx affected -t deploy with --base and --head set to the last successful deploy commit and the current commit limits the run to services whose project graph changed.

Set "cache": false explicitly on every deploy target. A deploy is a side effect against a live system, and Nx replays cached results by restoring outputs and terminal logs. A cache hit can therefore print a successful production deploy that never ran.

The risk usually comes from targetDefaults in nx.json. If someone marks a broad default cacheable, or names a deploy target after one that already has caching enabled, the deploy inherits that setting without anyone noticing. Add a deploy entry under targetDefaults with "cache": false so the rule holds even for projects that forget it.

Build targets are the opposite case. They are deterministic, so precise inputs and outputs make caching both safe and fast.

Parallel deploys are fine for independent services. When services share a database and its migrations, either pass --parallel=1 or make one service's migrate target a dependency of the others so ordering lives in the graph rather than in luck.

// nx.json: workspace-wide safety net. Any target named "deploy" is never cached,
// even if a project.json forgets to say so.
{
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    },
    "deploy": {
      "cache": false
    }
  }
}

// apps/billing-api/project.json: the deploy target for one service.
{
  "name": "billing-api",
  "targets": {
    "deploy": {
      "executor": "@acme/deploy-plugin:deploy",
      // Restate cache: false locally so the intent is visible where the target lives.
      "cache": false,
      // Run this project's build and test first, plus orders-api's migrate target,
      // because both services share one database schema.
      "dependsOn": [
        "build",
        "test",
        { "projects": ["orders-api"], "target": "migrate" }
      ],
      "options": {
        "environment": "production",
        "artifactPath": "{projectRoot}/dist"
      }
    }
  }
}

nx.json and project.json snippets that disable caching for deploy targets in targetDefaults and per project, keep caching for build, and use dependsOn to order deploy after build, test, and a shared migration target (Nx reads these files with a parser that accepts comments).

Secrets, permissions, and the limits of affected detection

The deploy executor should never hold a credential at rest. Read tokens and keys from process.env at the moment the executor runs, populated by the CI secret store. Do not give secret options a default in schema.json or a value in project.json, because both files are committed and both show up in nx show project output. Run a small redaction helper before any logger.info call that prints the resolved options. It walks the options object and replaces the value of any key matching token, secret, password, or key with a fixed placeholder. The helper exists because debug dumps tend to get added during an incident, which is the worst time for a credential to land in CI logs.

An executor has no identity of its own. It runs with the permissions of whoever invoked it. If an engineer's shell has production credentials, nx run api:deploy will deploy to production from a laptop. Nx cannot prevent this. Prevent it outside Nx: issue production credentials only to the CI runner through short-lived, scoped roles, and gate the environment with required approvals.

Affected detection compares git changes against the project graph. It does not observe what is actually running. A change to a shared Terraform module, a root-level config file, or a CI environment variable can leave a service unmarked, and its deploy is silently skipped. Declaring implicitDependencies covers files that live in the repository, but nothing covers values that live outside it. Keep a manually triggered pipeline that runs nx run-many -t deploy --all for these cases.

import { ExecutorContext, logger } from '@nx/devkit';

interface DeployOptions {
  service: string;
  region: string;
  dryRun?: boolean;
}

// Key names that must never reach a log line, at any nesting depth.
const SENSITIVE = /token|secret|password|key/i;

export function redact(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(redact);
  if (value !== null && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([k, v]) => [k, SENSITIVE.test(k) ? '[REDACTED]' : redact(v)])
    );
  }
  return value;
}

export default async function deployExecutor(
  options: DeployOptions,
  context: ExecutorContext
): Promise<{ success: boolean }> {
  // Read the credential at run time. The CI secret store injects it.
  // It is never a schema default or a project.json option.
  const token = process.env.DEPLOY_TOKEN;
  if (!token) {
    logger.error('DEPLOY_TOKEN is not set; refusing to deploy.');
    return { success: false };
  }

  // Every options dump goes through redact(), including ones added during an incident.
  logger.info(`Deploying ${context.projectName}: ${JSON.stringify(redact(options))}`);

  // ...pass `token` straight to the deploy client here. Do not add it to `options` or log it.
  return { success: true };
}

A deploy executor reads its credential from process.env at run time and passes the resolved options through a recursive redaction helper before logging them.

Testing the executor and rolling it out across services

Because the executor is a plain async function that returns { success }, you can unit-test it without Nx running at all. Route every shell call through one small wrapper module around child_process and mock that module in your tests. Then call the executor directly with a set of options and a minimal ExecutorContext, and assert the exact argument arrays the mock received. Write one test per configuration (staging, production, dry-run). Check full arrays, not substrings. Most deploy bugs are a missing flag or arguments in the wrong order, and a loose toContain check lets those through. The dry-run test also needs a negative assertion: no mutating command was spawned at all.

Unit tests prove the commands are built correctly. They don't prove the commands work. For that, run nx run <service>:deploy:sandbox against a throwaway environment with dryRun: true. This checks credentials, tool versions on the runner and network access without changing anything.

For rollout, define the deploy target once under targetDefaults in nx.json. That entry sets the executor reference, shared options and the standard configurations. Each service's project.json then declares only what differs, usually a service name and maybe a region. Reviewers can see at a glance what makes a service unusual.

Every service depends on this executor, so keep its option surface small and treat each option change as a breaking change. Add new options as optional with defaults in schema.json. Deprecate an option before removing it. Let schema validation reject unknown keys so a stale configuration fails loudly instead of being silently ignored.

When to avoid it, and the impact on cost and engineering time

Signs the executor is doing too much

Stop growing the executor once it starts owning retries, rollbacks, approval gates, or secret rotation. At that point you are building a deployment platform inside a build tool. A dedicated deployment tool, or your CI system's native environments, approvals, and secret stores, will usually handle those concerns with less code that you have to maintain.

The costs are real. You spend time up front building the plugin. New engineers have one more abstraction to learn. Upgrades cause friction because plugin and executor APIs have changed across Nx major versions. To limit that exposure, keep the executor thin. It should validate options, resolve paths, and call a standalone deploy script that also runs without Nx. When an upgrade breaks something, only the glue code needs fixing, and you can still deploy by hand.

Factor Cost Benefit
Shared logic Plugin build effort Fix once, applies to every service
Schema validation Schema upkeep Bad config fails before deploy
Local/CI parity Extra abstraction layer Fewer "works in CI only" failures
Affected-only deploys Graph accuracy must be maintained Fewer, shorter pipeline runs
Nx upgrades Migration work per major version None; thin wrappers reduce this cost

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs:

  • 10 services
  • 2 shared deploy-logic changes per month, each taking 0.5 h per service to copy by hand
  • 24 h to build the plugin
  • 2 h per month of maintenance
  • 16 h per year on upgrades, which is about 1.3 h per month

Monthly saving = changes × (services − 1) × hours = 2 × 9 × 0.5 = 9 h

Net monthly gain = 9 − 2 − 1.3 = 5.7 h

Payback = build cost ÷ net gain = 24 ÷ 5.7 ≈ 4.2 months

With 3 services, the saving drops to 2 h per month, which does not cover maintenance and upgrades. In that case, plain scripts are the better choice.

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 custom executor, 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.