Command Line, Processes, and Environment Variables

LESSON

Backend Development Foundations

002 25 min beginner

Command Line, Processes, and Environment Variables

By the end of this lesson, you will be able to...

  • Trace how a backend service starts from a command into a running process.

  • Separate command-line arguments, environment variables, files, ports, streams, and exit status.

  • Diagnose startup failures such as missing config, wrong ports, bad working directories, and misleading exit codes.

Idea in one sentence: A backend service is not just code on disk; it is a process started with an explicit contract, and many deployment bugs come from that contract being hidden or wrong.

Core Insight

In the previous lesson, a proxy returned:

502 Bad Gateway
error=connection_refused
upstream=auth-service:8080

The request reached the proxy, but it probably did not reach the backend handler. The next question is not "which route is broken?" The next question is smaller:

Is there a process listening where the proxy expects one?

That question moves us from request paths to process startup.

Imagine the same orders API. On your laptop it works:

npm run dev

On a teammate's laptop it exits with:

DATABASE_URL is required

In staging, the container starts, but the proxy still returns 502. The platform expected the service to listen on port 8080; the service printed:

Listening on 3000

The naive idea is:

Run the app.

That phrase hides the hard part. Before an API can handle a request, the operating system must start a process. That process receives inputs. It reads configuration. It opens files. It binds a port. It reports success or failure. If any of those startup steps are wrong, the handler may never run.

The useful model is:

parent starts process -> process receives startup inputs -> app validates contract -> app binds port or exits

This lesson makes that startup mechanism visible.

The Moving Parts

Use one small service through the lesson:

PORT=8080 DATABASE_URL=postgres://db/orders ./orders-api --mode web

That line looks like one command. It is really several inputs crossing one boundary.

The parent is the thing that starts the service. Locally, the parent may be your shell. In production, it may be Docker, Kubernetes, systemd, a process manager, a test runner, or a platform launcher.

The child is the backend process.

The child receives:

Plain meaning:

A startup contract is the list of things the process needs from its parent before it can do its job.

In this scenario:

The orders API needs a valid PORT, a valid DATABASE_URL, a mode, access to any required files, and a clear way to report whether startup succeeded.

Technical name:

We will call this the process startup contract.

This is a boundary, just like the boundaries in the request path. The proxy cannot forward to a service that did not start, did not bind the expected port, or exited while the platform thought it was healthy.

The Mechanism Step By Step

Follow the command from text to listening service.

Input:
  PORT=8080 DATABASE_URL=postgres://db/orders ./orders-api --mode web

Step 1: parent prepares the launch
  parent decides executable, arguments, environment, working directory, streams

Step 2: operating system creates a process
  process gets a process id, memory, file descriptors, permissions

Step 3: program starts running
  runtime exposes args, env vars, working directory, stdout, stderr

Step 4: app reads startup inputs
  mode=web
  PORT=8080
  DATABASE_URL=postgres://db/orders

Step 5: app validates required inputs
  port must be an integer from 1 to 65535
  database URL must exist and parse as a database connection string

Step 6: app opens required resources
  create logger
  connect or prepare connection pool
  load required files if any

Step 7: app binds the network port
  listen on 0.0.0.0:8080 or another configured interface

Output:
  success: process stays alive and reports ready
  failure: process writes a clear error and exits nonzero

The important detail is that startup has intermediate states. A service can exist as a process but still not be ready. It may be running, but not listening. It may be listening on the wrong port. It may be listening, but using fallback configuration that points to the wrong database.

That is why "the container is running" is not the same as "the backend is ready."

So far, the mechanism is:

start process -> validate contract -> bind expected port -> report readiness

Arguments, Environment, And Files

Command-line arguments, environment variables, and files can all configure a backend. They are not the same tool.

Command-line arguments are good for visible choices for this run:

./orders-api --mode web --workers 4

They are easy to see in the command. They are useful for choices like mode, one-time maintenance actions, or worker count. They are a poor fit for secrets because command text may appear in shell history, process listings, logs, or error reports.

Environment variables are good for deploy-time configuration:

PORT=8080
DATABASE_URL=postgres://db/orders
LOG_LEVEL=info

They let the same artifact run in different places. The code can be the same in development, staging, and production while the parent supplies different values. The cost is that environment variables are strings. PORT=abc is still an environment variable. It becomes a useful setting only after the app parses and validates it.

Files are good for structured data that is deliberately packaged or mounted:

config/routes.json
schema.sql
certificates/ca.pem

Files bring their own questions. Is the file present in the container image? Is it mounted by the platform? Is the working directory what the app expects? Can the process read it with its permissions?

The design decision is not "arguments versus environment versus files forever." The decision is:

Which parent supplies this value?
When should the app reject it?
How will a human see what happened?

A Worked Startup Trace

Now trace a failure that explains the 502 from the previous lesson.

The deploy platform starts the process like this:

PORT=8080 DATABASE_URL=postgres://db/orders ./orders-api --mode web

The proxy is configured to forward to:

orders-service:8080

The application code accidentally ignores PORT and uses a hard-coded default:

listen_port = 3000

The trace looks like this:

Input:
  parent supplies PORT=8080
  proxy expects orders-service:8080

Transition:
  process starts successfully
  app reads DATABASE_URL
  app ignores PORT
  app binds 0.0.0.0:3000

Intermediate state:
  process is running
  port 3000 is listening
  port 8080 is not listening

Output:
  app prints "Listening on 3000"
  platform sees a process
  proxy connection to 8080 is refused
  user sees 502 Bad Gateway

Naive failure contrast:
  "The app is running, so the proxy must be wrong."

Better diagnosis:
  The process exists, but it violated the startup contract.
  It did not listen on the port supplied by its parent.

This is the small trap. A process can be alive and still unusable by the rest of the system. The startup contract connects the process boundary to the request boundary.

Check: If the proxy says connection_refused for orders-service:8080, and the app log says Listening on 3000, should you change the route handler first?

Think first, then reveal.

Answer: No. The request has not reached the handler. The first fix is in the startup contract: the app must read and validate the parent-supplied port, or the proxy must be configured to the port the app actually binds.

Fail Fast, Then Report Clearly

Some startup failures should stop the process immediately.

If DATABASE_URL is required for normal requests, this is a bad startup behavior:

DATABASE_URL missing
using development fallback database
Listening on 8080
exit status: still running

The process is alive, but it is now dangerous. It may accept real traffic while pointing at the wrong database.

A better behavior is:

stderr:
  startup error: DATABASE_URL is required

exit status:
  nonzero

This gives the parent a clear signal. A deploy platform, process manager, or test runner can treat nonzero exit status as startup failure. A human can read stderr and see the missing input.

Use this startup rule:

If the service cannot keep its basic promise, fail before accepting traffic.

That rule is not about being dramatic. It is about avoiding ambiguous failures later. A missing database URL discovered at startup is one clear problem. A missing database URL discovered after users send traffic becomes many symptoms: 500s, retries, noisy logs, and maybe corrupted assumptions.

Check: A service prints missing PORT, then exits with status 0. What is wrong with that signal?

Think first, then reveal.

Answer: Exit status 0 usually means success. If startup failed, the process should exit nonzero so automation can detect the failure. Text alone is not enough when another program is supervising the service.

Common Confusions

Confusion: "Environment variables are configuration, so they must be valid"

Why it is tempting:

The parent supplied a value, so it feels official.

Better model:

Environment variables are untrusted strings until the app parses and validates them. PORT=8080 may be valid. PORT=eight is just text. The app owns the validation boundary.

Confusion: "A running process means the service is ready"

Why it is tempting:

Process lists, container dashboards, and local terminals often show whether something is running.

Better model:

Running only means the operating system has a live process. Ready means the process has satisfied the startup contract and can accept the kind of work its parent or proxy will send. A service can be running while listening on the wrong port, missing a required file, or failing dependency setup.

Confusion: "Local .env files are the contract"

Why it is tempting:

Local .env files are convenient. They make the app start quickly while developing.

Better model:

A .env file can help local development, but the contract is the named set of required inputs and validations. If production does not have the same file, or if the file is untracked and undocumented, the real contract is hidden in one machine.

Trade-offs And Limits

The main trade-off is portable startup versus local convenience.

Explicit startup contracts make services easier to move from laptop to test runner to container to production. They also require discipline. You need to name required inputs, validate them, choose good defaults carefully, document how the parent supplies them, and avoid logging secrets while explaining failures.

This helps when:

the same service must start in more than one environment
automation must decide whether startup succeeded
humans need clear failure messages

It costs:

more validation code
more startup tests
less tolerance for "it works on my shell"

It does not solve every configuration problem. Environment variables are not a full secrets-management system. They do not handle rotation, access control, audit trails, or secret leakage by themselves. Later lessons will return to configuration and secrets with more care.

The signal that you are near the boundary is simple: people start saying "but it works on my machine." That often means the process contract is living in one machine instead of in the service's explicit startup behavior.

Practice

Design a startup contract for this service:

image-resizer --workers 4

The service reads jobs from a queue, writes resized images to object storage, and exposes a health endpoint for the platform.

Write a small contract with:

A good answer should mention:

Resources

Key Takeaways

PREVIOUS Internet Request Paths for Backend Developers NEXT Git, Branches, and Reviewable Change History