FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·118·13 MIN READ

118: Process, Environment Variables, CLI Input/Output, Signals, and Exit Codes

TOPICS COVERED: Process, Environment Variables, CLI Input/Output, Signals, and Exit Codes

Learning objectives

You will learn to:

  • use process.argv, process.env, stdin, stdout, and stderr;
  • build a well-behaved CLI;
  • distinguish configuration from secrets and defaults;
  • validate environment variables at startup;
  • understand current working directory and process metadata;
  • handle Unix-like process signals conceptually;
  • set exit codes correctly;
  • understand signal portability;
  • avoid leaking secrets in logs;
  • use AbortController to coordinate shutdown inside application code.

process

When a Node program needs to inspect its runtime or respond to its operating environment, the built-in process object is the starting point. It exposes information about the current process and controls for influencing its behavior:

js
console.log(process.pid);
console.log(process.cwd());
console.log(process.platform);
console.log(process.arch);
console.log(process.env.NODE_ENV);

These values answer practical debugging questions: which process is running, where it considers its working directory to be, which platform and CPU architecture it is using, and what environment configuration it received. Treat process as runtime infrastructure, not as a global dumping ground. Read from it at clear boundaries and pass the resulting values into the parts of the application that need them.

That boundary also makes the code easier to test. Instead of having business functions reach into process.env or inspect process.argv themselves, the entry point can translate runtime data into ordinary arguments and hand those values to the application layer.

Command-line arguments

Suppose a command-line program is invoked like this:

bash
node cli.js add "Write docs" --priority high

Node exposes the command-line arguments through process.argv, and every entry is a string. The array also includes the Node executable and the script path, so the application-specific arguments are not at index zero.

js
console.log(process.argv);

For the command above, the array typically looks like this:

js
[
  '/path/to/node',
  '/path/to/cli.js',
  'add',
  'Write docs',
  '--priority',
  'high'
]

Application arguments usually begin at index 2:

js
const args = process.argv.slice(2);

That slice is worth making explicit because it gives the rest of the CLI a stable, simpler input: the command and its options, without the runtime bookkeeping entries. For non-trivial CLIs, use a mature parser such as Commander, yargs, or another reviewed package rather than writing an ambiguous parser yourself. First understand raw argv, though. A parser library is easier to use correctly when you understand what it is parsing and how arguments are represented.

Arguments can be absent, repeated, or supplied in an order the program did not expect. Treat them as untrusted input and validate the command-specific shape before performing the requested operation.

Simple command parser

This small parser is enough to show the basic control flow for two commands:

js
const [command, ...rest] = process.argv.slice(2);

switch (command) {
  case 'list':
    console.log('Listing tasks');
    break;

  case 'add': {
    const title = rest.join(' ').trim();

    if (!title) {
      console.error('Usage: task add <title>');
      process.exitCode = 2;
      break;
    }

    console.log(`Adding: ${title}`);
    break;
  }

  default:
    console.error('Unknown command');
    process.exitCode = 2;
}

The destructuring assigns the first application argument to command and leaves the remaining arguments in rest. The add branch joins those remaining strings so a title containing spaces can be accepted. Missing input and unknown commands are user errors, so the message goes to stderr and the process records a non-zero exit status.

This is sufficient for learning, not a production-grade flag parser. In a real CLI, you would also need deliberate handling for flags, quoting, help text, validation, and possibly subcommand-specific options.

stdout and stderr

A CLI normally has two different kinds of output. Data that another command might consume belongs on stdout:

js
process.stdout.write('result\n');

Diagnostics and errors belong on stderr:

js
process.stderr.write('invalid input\n');

Why keep them separate? A user may pipe the data output into a file or another program:

bash
task-cli list > tasks.txt

Errors still appear in the terminal because the redirection targets stdout, not stderr. A CLI that prints every message to stdout becomes difficult to compose in shell pipelines: a downstream program may mistake a warning or stack trace for valid data.

console.log

In normal Node usage, console.log() writes to stdout and console.error() writes to stderr. That makes them convenient for small examples and simple command-line tools.

For structured production logging, use a logger designed for machine-readable logs rather than relying on ad-hoc console statements. A logging system can add consistent levels and metadata and can redact sensitive values. The stdout/stderr distinction still matters even when a logger is involved, because deployment systems often collect or route those streams differently.

Reading stdin

Standard input is a stream, so input may arrive in multiple chunks rather than as one complete string. Node supports async iteration over that stream:

js
process.stdin.setEncoding('utf8');

let input = '';

for await (const chunk of process.stdin) {
  input += chunk;
}

console.log(input.trim());

The loop continues until stdin ends, then the accumulated text is trimmed and printed. Because stdin is a stream, async iteration works naturally here. For very large input, accumulating everything in memory may not be appropriate; later you will study stream backpressure and transformations.

When debugging a command that appears to hang, check whether it is waiting for stdin to end. A terminal may keep stdin open until the user signals end-of-input, while a pipe normally closes it after the producer finishes.

TTY versus pipe

A CLI may need to behave differently when a person is using it interactively and when another program is consuming its output. Node exposes whether the standard streams are connected to a terminal:

js
process.stdin.isTTY
process.stdout.isTTY

Interactive terminal behavior can differ from piped input. A useful operating model is:

text
TTY → show prompts/colors/progress
pipe → emit clean machine-readable output

Do not send ANSI color codes into JSON redirected to a file unless the user explicitly requested them. Terminal decoration is useful for a person, but it can corrupt data intended for a parser.

The same rule applies to progress bars and informational banners. Keep human-oriented presentation conditional on the terminal, and keep data-oriented output stable for scripts.

Environment variables

Environment variables provide values to a process from its surrounding shell, service manager, container platform, or deployment system. Read one through process.env:

js
const port = process.env.PORT;

Environment values are strings or undefined. They do not arrive as numbers or booleans just because their names suggest those types. This common defaulting expression illustrates the first subtlety:

js
const port = process.env.PORT || 3000;

When PORT is configured, port is a string, not a number. That may remain hidden until code performs numeric comparison or passes the value to an API that expects a number. Parse and validate at the configuration boundary instead:

js
function readPort() {
  const raw = process.env.PORT ?? '3000';
  const value = Number(raw);

  if (!Number.isInteger(value) || value < 1 || value > 65535) {
    throw new Error(`Invalid PORT: ${raw}`);
  }

  return value;
}

This function distinguishes an absent variable from a supplied value, converts the value once, and rejects anything outside the valid TCP port range. The rest of the application can then work with a number and does not need to repeat the same checks.

Validate configuration once

Configuration errors are usually easiest to diagnose at startup, before the service accepts requests or begins background work. A small configuration module can centralize required values and defaults:

js
// config.js
function required(name) {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

export const config = Object.freeze({
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: required('DATABASE_URL'),
  nodeEnv: process.env.NODE_ENV ?? 'development',
});

This establishes one place to read the process environment and makes the resulting configuration object immutable. Improve it by validating every type and range, including the port value shown here. Required strings may need more specific checks as well, depending on the application.

Reading configuration once also prevents different modules from applying different defaults to the same setting. If one module treats a missing value as development mode while another treats it as an error, the process can start with internally inconsistent assumptions.

Libraries such as Zod or Valibot can validate configuration schemas, but do not make configuration validation dependent on request-time code. Fail at startup. A deployment should report a missing or malformed setting immediately instead of starting a process that will fail only when a particular route is used.

.env files

Environment-file tooling is convenient in local development because it lets a developer provide several variables without configuring each shell session manually. Modern Node releases also include environment-file capabilities and CLI options; verify the target Node version before relying on a particular option or behavior.

A .env file is not a production secret manager. Never commit real secrets such as:

text
DATABASE_URL with production password
JWT signing secret
private API credentials

Use the platform's secret and configuration facilities for deployed services. Add local files to .gitignore where appropriate, and treat any value that has already been committed as potentially exposed rather than assuming that removing it from the latest commit is enough.

Configuration categories

It helps to classify values by how they change and how sensitive they are. The categories are related, but they should not automatically be managed in the same way.

Code constant

text
MAX_USERNAME_LENGTH = 80

This is a business or program invariant. It normally changes with the code and should be reviewed and tested as part of the application.

Environment config

text
PORT
LOG_LEVEL
DATABASE_URL

These values are deployment-specific. A staging deployment and a production deployment may use different values without requiring different application code.

Secret

text
DB_PASSWORD
OAUTH_CLIENT_SECRET
SIGNING_KEY

These are sensitive deployment values. Their handling needs access controls, rotation procedures, and redaction from logs in addition to ordinary configuration management.

Runtime feature flag

A feature flag may come from a dedicated configuration or feature service. That is often a better fit than putting every changing business rule into environment variables. Environment variables are typically read at process startup, while a feature service may support controlled changes at runtime.

Secret logging

Never do this in production:

js
console.log(process.env);

The object can contain database passwords, cloud credentials, tokens, and signing keys. A debugging shortcut can therefore become a credential-disclosure incident when logs are retained, shipped to a third party, or viewed by people who do not need access to the secrets.

Redact structured logs and log only the configuration fields that are safe and useful for diagnosis. Prefer reporting that a value is present, or logging a non-sensitive classification, rather than printing the value itself.

Be careful with indirect disclosure as well. A full connection string, an exception containing credentials, or a serialized options object can expose the same secret even when process.env itself is never logged.

Current working directory

The current working directory is the directory from which the process is operating, not necessarily the directory containing the JavaScript file:

js
console.log(process.cwd());

Node can change it during execution:

js
process.chdir('/tmp');

Avoid changing cwd in application servers unless you have a strong reason. It changes relative path behavior process-wide, which can make unrelated code read or write a different location than expected.

When an application needs its own resources, resolve them relative to the module when appropriate rather than assuming that the current working directory is the project directory. This distinction becomes especially important when a service is launched by a supervisor, from a different directory, or through a packaged deployment.

Exit codes

An operating system or calling script needs a reliable way to tell whether a process completed successfully. Set an exit code and allow normal event-loop completion:

js
process.exitCode = 1;

Setting process.exitCode records the eventual status while allowing pending asynchronous work, including stream flushing, to complete. By contrast, process.exit(code) exits immediately.

That difference matters here:

js
process.stdout.write(largeOutput);
process.exit(0);

The output may be truncated because the process can terminate before the stream finishes writing. For a CLI, set exitCode and return from the top-level flow instead. Let the event loop drain normally unless immediate termination is specifically required and the consequences are understood.

Shell scripts and orchestration systems use this status, not the wording of a log message, to detect failure. Choose codes consistently within a CLI and make sure validation failures do not accidentally produce a successful zero status.

Top-level CLI pattern

One useful pattern is to put the main operation in an async function and keep one error boundary around it:

js
async function main() {
  const [command] = process.argv.slice(2);

  if (!command) {
    throw new Error('Command required');
  }

  ...
}

try {
  await main();
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}

The main function owns normal application flow, while the try/catch provides one clear place to convert an expected top-level failure into diagnostic output and a non-zero status. More detailed CLI code can choose different codes for usage errors and operational failures, as the earlier parser did, but it should still preserve the separation between the error message and process termination.

Signals

Long-running services do not only stop because their JavaScript reaches the end of a file. Operating systems and process managers can send signals to request interruption or shutdown.

Common Unix signals include:

text
SIGTERM
SIGINT

SIGINT often results from pressing Ctrl+C in a terminal. Container and process managers commonly use SIGTERM to request a graceful stop. Node can listen for these signals:

js
process.once('SIGTERM', () => {
  console.log('received SIGTERM');
});

process.once('SIGINT', () => {
  console.log('received SIGINT');
});

The once handlers make the intent to begin handling each shutdown request explicit. Signal behavior differs across operating systems, however. Do not assume every Unix signal exists identically on Windows, or that the same process-manager behavior is available on every platform.

Test signal handling in the environment where the service will run. A handler that works from a local terminal is not by itself proof that the container, supervisor, or Windows host will deliver and wait for the same signal sequence.

Graceful shutdown concept

A graceful shutdown is an ordered transition, not just a log message followed by termination:

text
signal
→ stop accepting new work
→ abort/cancel background work where appropriate
→ finish/timeout in-flight requests
→ close database/message connections
→ flush logs
→ set exit status / let process exit

The order gives new work less opportunity to arrive while existing work is given a bounded chance to finish. Cleanup must also have a timeout or other failure policy; a service that waits forever is not graceful from the platform's perspective. A later production lesson implements this fully.

The exact cleanup steps depend on the service. An HTTP server may stop listening first, while a worker may stop pulling messages and finish the job it already claimed. The general principle remains the same: make the shutdown boundary explicit and avoid starting new work after it begins.

AbortController for shutdown

For application code that supports cancellation, an AbortController turns an operating-system signal into a normal internal signal:

js
const shutdownController = new AbortController();

process.once('SIGTERM', () => {
  shutdownController.abort(new Error('shutdown'));
});

Pass its signal:

js
shutdownController.signal

to internal jobs that support cancellation. Those jobs can stop cooperatively when the signal is aborted, rather than requiring every function to inspect a shared global boolean. This creates a composable shutdown signal and provides a path to combine shutdown with other cancellation conditions.

beforeExit and exit

Node process events exist, but they are not a general graceful-shutdown mechanism. The exit event is too late for ordinary asynchronous cleanup: once Node is in that phase, asynchronous work cannot be relied on to finish.

Shutdown should begin when the process receives a signal or reaches an application failure condition. That gives the application time to stop accepting work, cancel or finish supported operations, close connections, and flush useful observations before the event loop ends.

Uncaught fatal errors

Do not make this your recovery strategy:

js
process.on('uncaughtException', error => {
  console.error(error);
  // continue forever
});

After an uncaught exception, application invariants may already be compromised. Continuing indefinitely can leave a service running while its in-memory state or resource ownership is no longer trustworthy.

The goal is not to hide the failure or make the process look healthy. Capture enough safe context to diagnose it, then rely on a supervisor and a clean restart to restore a known state.

A production service should:

  • log/observe safely;
  • begin controlled shutdown if possible;
  • let a process supervisor restart it.

The dedicated error and production lessons go deeper into the policies and mechanics involved.

CLI project: environment checker

This small CLI checks that required names exist without printing their values:

js
const required = [
  'DATABASE_URL',
  'APP_ENV',
];

const missing = required.filter((name) => !process.env[name]);

if (missing.length) {
  console.error(`Missing: ${missing.join(', ')}`);
  process.exitCode = 1;
} else {
  console.log('Configuration OK');
}

Notice the security boundary: the diagnostic identifies which names are missing, but it does not print secret values. It also uses process.exitCode, so the message can be emitted normally and the caller can reliably detect failure.

This check is useful as an early deployment diagnostic, but presence alone is not full validation. A variable can exist and still contain an invalid URL, an unsupported environment name, or an empty value that the application cannot use.

Interactive prompts

For a polished interactive CLI, use packages such as Inquirer or prompts when justified. A prompt library should stay at the terminal boundary. The parser and prompt layer should return plain domain values:

js
{
  title: 'Review Node',
  priority: 'high'
}

That separation keeps core logic testable without a terminal. Business logic can receive an ordinary object in a unit test, while the interactive layer remains responsible for collecting and validating user input.

Common mistakes

  • parsing every flag manually in a large CLI;
  • printing errors to stdout;
  • assuming env vars have number/boolean types;
  • committing secrets;
  • logging whole environment;
  • using process.exit immediately after async output;
  • running async cleanup from exit;
  • assuming SIGTERM semantics are identical on every platform;
  • making global process state the business layer.

Exercises

  1. Build task list and task add argv parsing.
  2. Separate data output and diagnostic output.
  3. Support piped stdin.
  4. Validate PORT and DATABASE_URL.
  5. Add --json output and ensure diagnostics remain stderr.
  6. Set meaningful exit codes.
  7. Add SIGINT handling that triggers an AbortController.
  8. Explain what happens if a server ignores SIGTERM in a container platform.

These exercises move from recognizing process interfaces to composing them into a CLI and then reasoning about shutdown behavior. For the JSON exercise, test both sides of the boundary: stdout should remain valid JSON for a successful command, while a diagnostic should still be available on stderr. For the signal exercise, distinguish receiving the signal from actually stopping work; the controller must be connected to cancellable application code.

Mastery checklist

Explain:

  • argv;
  • stdin/stdout/stderr;
  • TTY;
  • environment strings;
  • config validation;
  • secrets;
  • exitCode versus exit();
  • signals;
  • graceful-shutdown lifecycle;
  • AbortController as cancellation infrastructure.

Official references

Reader page: /nodejs/lesson/118/process-environment-variables-cli-input-output-signals-and-exit-codes