26 August 2026

Graceful shutdown in containers: fixing the SIGTERM chain

A feature flag SDK that never receives a shutdown signal leaks connections and loses analytics events. Here is how I traced the problem back to PID 1 and fixed it properly.

Running a Node.js server inside a container introduces a class of problem that does not exist when running Node directly: the process that receives the shutdown signal is not necessarily the process your shutdown handler lives in.

I ran into this when working with a feature flag SDK that requires an explicit close call on shutdown. The handler in server code never fired. The SDK connection leaked on every deployment. Understanding why took tracing the full signal chain from Kubernetes down to Node, and the fix required changes at two levels of the stack.

Why shutdown matters for a feature flag SDK

The cost of not shutting down cleanly

What happens when the SDK does not shut down

why this matters

Feature flag SDKs like LaunchDarkly buffer analytics events locally and flush them to the platform on a configurable interval. These events are what LaunchDarkly uses to populate its Contexts list, track flag evaluations, support experimentation, and power guarded rollouts.

The Node.js server-side SDK has a specific behaviour worth knowing: unlike other LaunchDarkly SDKs, it does not automatically flush pending analytics events when it shuts down. You must call flush() explicitly before calling close(). If the process terminates without this, any events buffered since the last automatic flush interval are lost.

There is also a cost dimension. LaunchDarkly's pricing is based on client-side monthly active users (MAU) and server-side service connections. Analytics events are what the platform uses to track unique contexts. If events are consistently dropped on shutdown, context data becomes incomplete. In experimentation or guarded rollout scenarios, that lost data can skew results and affect decisions built on them.

The signal chain problem

Why the shutdown handler never fired

PID 1 and the npm signal problem

why this matters

When Kubernetes terminates a pod, it sends SIGTERM to PID 1 inside the container. In a Dockerfile with CMD ["npm", "run", "start"], PID 1 is npm, not Node.

npm receives SIGTERM and exits without forwarding the signal to its child processes. Node never receives the signal. The process.on('SIGTERM') handler in server code never fires. The SDK's close() method is never called. The connection leaks on every deployment.

# This makes npm PID 1 - signals stop here
CMD ["npm", "run", "start"]

This is a well-known footgun with npm as an entrypoint. It is also easy to miss because the container terminates eventually anyway via Kubernetes' SIGKILL after the grace period, so the application appears to stop correctly. The leak is invisible unless you are specifically watching for it.

Adding tini as a signal forwarder

why this matters

tini is a minimal init system designed specifically for containers. It handles PID 1 responsibilities correctly: it forwards signals to its child processes and reaps zombie processes. Adding it means SIGTERM from Kubernetes reaches the child process rather than dying at npm.

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["npm", "run", "start"]

This was necessary but not sufficient. tini forwards the signal to npm. npm still does not forward it to Node. The chain is one step longer but the same break exists at the npm boundary.

Bypassing the chain with a custom entrypoint script

why this matters

The real fix was a docker-entrypoint.sh script that bypasses the npm layer entirely and talks directly to the Node process.

#!/bin/sh
set -e

# Start Node directly in the background
node dist/server.js &
NODE_PID=$!

# Trap SIGTERM, find Node's PID, forward the signal directly
trap 'kill -SIGTERM $NODE_PID' TERM

# Wait for Node to exit cleanly
wait $NODE_PID
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["docker-entrypoint.sh"]

The full signal chain now works correctly:

Kubernetes sends SIGTERM to the container. tini receives it and forwards to docker-entrypoint.sh. The script's trap fires, finds Node's PID, and sends SIGTERM directly to Node. Node's process.on('SIGTERM') handler in server code fires. The SDK shutdown sequence runs.

The shutdown handler

What the server-side cleanup looks like

Handling SIGTERM in server code

why this matters

With the signal chain fixed, the shutdown handler in server.ts now fires reliably. For the Node.js SDK specifically, the sequence must be explicit: flush first, then close.

process.on("SIGTERM", async () => {
  logger.info("SIGTERM received, starting graceful shutdown");

  try {
    await ldClient.flush();
    logger.info("LaunchDarkly events flushed successfully");
  } catch (err) {
    logger.error("LaunchDarkly flush failed", { error: err });
  }

  try {
    await ldClient.close();
    logger.info("LaunchDarkly client closed");
  } catch (err) {
    logger.error("LaunchDarkly close failed", { error: err });
  }

  process.exit(0);
});

Logging each step separately is deliberate. If flush() fails but close() succeeds, or vice versa, the logs tell you exactly which step broke and whether events were lost. A single catch around both operations collapses that information.


The full picture

What the complete signal chain looks like

StepComponentWhat happens
1KubernetesSends SIGTERM to PID 1 in the container
2tiniReceives SIGTERM, forwards to docker-entrypoint.sh
3docker-entrypoint.shTrap fires, sends SIGTERM directly to Node's PID
4Node processprocess.on('SIGTERM') handler fires in server.ts
5SDK clientflush() sends buffered analytics events
6SDK clientclose() closes the open connection
7Node processprocess.exit(0) exits cleanly

Closing

Final thoughts

The root cause here was a mismatch between where the signal arrives and where the handler lives. npm as PID 1 is an extremely common pattern, and its signal-swallowing behaviour is not obvious until something downstream requires a clean shutdown.

The two-layer fix, tini plus a custom entrypoint script, is more code than you might expect for a signal forwarding problem. The reason is that tini only solves half of it: it correctly forwards signals from PID 1 to its child, but if that child is npm, the signal still does not reach Node. The entrypoint script is what closes the final gap by starting Node directly and forwarding signals to it explicitly.

The broader lesson is worth keeping in mind for any dependency that holds open connections or buffers state. If the process that owns the cleanup handler never receives a shutdown signal, that cleanup never runs. Tracing the full signal chain from the orchestrator to your application code is the first thing to check when shutdown handlers behave unexpectedly in a containerised environment.