SIGTERM Handling and Graceful Shutdown in Containerized Apps
AI generated
FROM
RUN
Docker · Operations · Signal Handling
SIGTERM Handling and Graceful Shutdown
doing it right in containerized apps

docker stop sends a SIGTERM and waits, yet many applications never respond to it and get hard killed once the deadline passes. Understanding how signal forwarding, PID 1, and the grace period interact is the key to avoiding dropped connections and data loss during deployments.

16 min read SIGTERM graceful shutdown docker stop -t PID 1 zero downtime

1. What actually happens when a container is stopped

When docker stop is invoked, Docker first sends a SIGTERM signal to the container's main process, that is, to PID 1 inside the container namespace. This signal is a polite request: please terminate yourself and clean up along the way. The process gets a certain window of time, the so called grace period, to respond. If it does not respond in time, Docker sends a SIGKILL, which terminates the process immediately with no chance to clean up anything.

This exact sequence is where many containerized applications fall apart: they do not react to SIGTERM at all, either because the process itself never registers a signal handler, or because the signal never reaches it in the first place. The outcome is always the same regardless of the exact cause: the container appears to sit there unaffected for the entire grace period, until SIGKILL arrives and open database transactions, in flight HTTP requests, or file writes get abruptly cut off.

2. Why PID 1 plays a special role inside containers

On a normal Linux system, PID 1 is the init process, such as systemd, which is specifically built to handle signals correctly and forward them to child processes. The Linux kernel treats PID 1 differently from every other process, though: for signals without a registered handler, no default behavior is applied. An ordinary process without its own handler would be terminated by SIGTERM automatically, but PID 1 silently ignores the signal in that case unless the application explicitly registers its own handler for it.

Inside a container, the main process is practically always PID 1, because Docker does not insert its own init process unless you explicitly configure it to. So if you start node server.js or php-fpm directly as CMD in the Dockerfile, that process runs as PID 1 and has to handle signals itself. Many frameworks and runtimes, however, were never designed to run as an init process, and therefore ship with no SIGTERM handler out of the box.


# Check PID 1 inside the container
docker exec mycontainer ps -o pid,comm -p 1

# Show a running process's signal handlers (SigCgt field, hex bitmask)
docker exec mycontainer cat /proc/1/status | grep Sig

3. The shell form trap: CMD and signal forwarding

One especially common cause of an ignored SIGTERM lies in how CMD and ENTRYPOINT are written in the Dockerfile. If the command is written in shell form, for example CMD node server.js, Docker internally runs it as /bin/sh -c "node server.js". That makes the shell PID 1 instead of the application, and most shells like sh or bash do not forward received signals to their child processes by default.

The fix is to use the exec form of CMD, written as a JSON array: CMD ["node", "server.js"]. This starts the process directly, with no enclosing shell, and it receives SIGTERM immediately as PID 1. This difference looks cosmetic at first glance, but in practice it decides whether a graceful shutdown ever gets a chance to trigger at all.


# WRONG: shell form, the shell becomes PID 1, does not forward SIGTERM
CMD node server.js

# CORRECT: exec form, the application itself becomes PID 1
CMD ["node", "server.js"]

# The same applies to ENTRYPOINT
ENTRYPOINT ["php-fpm", "--nodaemonize"]

4. Implementing graceful shutdown in Node.js applications

In Node.js, a SIGTERM handler can be registered with process.on. It is important that the server does not shut down abruptly but instead stops accepting new connections while letting in flight requests finish. The HTTP server object provides a close method for exactly this behavior: it closes the listening socket immediately but waits for all already open connections to finish before invoking its callback.

In addition, database connection pools, message queue connections, and open file handles should be closed explicitly instead of relying on implicit cleanup at process exit. A safety net timeout is also worth having, so a single hanging request cannot block the entire shutdown indefinitely and delay the process all the way to SIGKILL.


# server.js (Node.js graceful shutdown)
const http = require('http');
const server = http.createServer(app);
server.listen(3000);

let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Received ${signal}, shutting down gracefully...`);

  server.close(async () => {
    console.log('No more open HTTP connections.');
    await dbPool.end();       // close database connections
    await messageQueue.close(); // close queue connection
    process.exit(0);
  });

  // Safety net: force exit after 10s if close() hangs
  setTimeout(() => {
    console.error('Shutdown timeout reached, forcing exit.');
    process.exit(1);
  }, 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

5. Implementing graceful shutdown in PHP applications (PHP-FPM)

PHP-FPM behaves differently from a classic Node.js process, because it uses a short lived worker process per request instead of running a permanent event loop. The PHP-FPM master process already handles SIGTERM sensibly by default: it stops accepting new requests but waits for running workers to finish their current request before terminating itself. It is important to run PHP-FPM in the foreground as PID 1, with the --nodaemonize option, instead of using the classic daemon mode.

For long running PHP processes outside of FPM, for example a Swoole or ReactPHP based worker, the developer has to handle signals manually, similar to Node.js. The pcntl extension provides pcntl_signal for registering a callback for SIGTERM. It is crucial to enable pcntl_async_signals(true), so signals are caught asynchronously between processing steps instead of only at explicit pcntl_signal_dispatch calls.


# Dockerfile: PHP-FPM in the foreground as PID 1
FROM php:8.4-fpm-alpine
COPY php-fpm.conf /usr/local/etc/php-fpm.d/zz-custom.conf
CMD ["php-fpm", "--nodaemonize"]

6. docker stop -t: configuring the grace period

By default, docker stop waits ten seconds between SIGTERM and the final SIGKILL. This deadline can be adjusted via the -t (or --time) option, either for a single invocation or permanently through stop_grace_period in the Compose file. For applications that typically handle only short lived requests, ten seconds is usually enough. Applications with long running operations, such as file uploads, report generation, or WebSocket connections, may need considerably more time.

A grace period that is too short results in graceful shutdown logic that is correctly implemented but simply does not finish in time before SIGKILL strikes. A grace period that is too long, on the other hand, needlessly slows down deployments and rolling updates, because every container replacement then takes as long as the configured wait time, even when the application has long since finished shutting down. The right balance comes from the realistic maximum duration of the longest running operation plus a safety margin.


# docker-compose.yml
services:
  api:
    image: mironsoft/api:latest
    stop_grace_period: 30s   # SIGTERM, then wait up to 30s before SIGKILL
    stop_signal: SIGTERM      # default, but documented explicitly

# Equivalent via CLI:
# docker stop -t 30 api-container

7. docker stop vs. docker kill: an important difference

Besides docker stop, there is a second command, docker kill, that looks similar at first glance but behaves fundamentally differently. By default, docker kill sends a SIGKILL immediately, with no preceding SIGTERM and no grace period whatsoever. The container terminates instantly, regardless of whether requests are currently being handled or database transactions are still open. For production operation, that is almost always the wrong choice.

docker kill is still useful, but for a different purpose: as a last resort when a container simply refuses to respond despite a sent SIGTERM, for example because it is stuck in a deadlock, or for testing purposes where a hard failure similar to a power outage is deliberately being simulated. docker kill also supports sending a signal other than SIGKILL via the -s option, for example docker kill -s SIGTERM, but that runs immediately with no grace period, unlike docker stop, which actively waits for the process to end after sending the signal.

8. Detecting and avoiding SIGKILL

A container that is regularly terminated via SIGKILL instead of shutting down cleanly shows up in the docker events log and in the container's exit code, frequently as exit code 137, which corresponds to 128 plus signal 9. This value is a reliable diagnostic signal that the graceful shutdown path is not working, either because no handler is registered, because the shell form is blocking the signal, or because the grace period is set too short.

For systematic troubleshooting, it helps to test the shutdown process locally by running docker stop with a very short grace period against the container's live logs and observing whether the custom shutdown logic even gets invoked. If the expected log lines like received SIGTERM never show up, the problem usually lies with PID 1 signal handling or the shell form in CMD, not with the actual application logic.

9. How it all fits together: a complete flow

A clean shutdown sequence looks like this: the orchestrator or administrator calls docker stop, Docker sends SIGTERM to PID 1 inside the container. The application, correctly started as an exec form CMD, receives the signal through its registered handler, stops accepting new connections, lets existing requests finish, cleanly closes database pools and queue connections, and finally terminates itself with exit code 0, all within the configured grace period.

In load balancer backed setups, an additional layer comes into play: the load balancer should ideally take the container out of rotation even before SIGTERM is sent, for example through a readiness check that fails shortly before the stop, so new requests never even reach the dying container. This combination of application level signal handling, correct PID 1 configuration, and an appropriate grace period is the foundation of genuine zero downtime deployments.

Cause Symptom Fix Affected component
CMD in shell form SIGTERM never reaches the app, exit code 137 Write CMD as a JSON array (exec form) Dockerfile
No signal handler registered Container hangs until the grace period expires Add process.on('SIGTERM') or pcntl_signal Application code
Grace period too short Shutdown logic starts but gets cut off Increase stop_grace_period to match the longest operation docker-compose.yml
Load balancer unaware of shutdown Requests land on a dying container Fail the readiness check before SIGTERM Orchestration/LB

Mironsoft

Container infrastructure, CI pipelines and deployment automation

Docker setups that hold up across the team and in production?

We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.

Dockerfile Review

Systematically optimizing multi-stage builds, layer caching and image size.

Security Audit

Hardening container isolation, secrets handling and image scanning against real attack surfaces.

CI/CD Integration

Building build pipelines, registries and deployment strategies for reproducible releases.

10. Summary

Graceful Shutdown in Containers: Key Takeaways

PID 1 problem

Without a registered handler, PID 1 ignores SIGTERM, unlike ordinary processes.

Exec instead of shell form

Writing CMD as a JSON array prevents a shell from swallowing the signal.

Explicit handling

Node.js and PHP need explicit code to close connections cleanly.

Tune the grace period

Match stop_grace_period or docker stop -t to the longest real operation.

11. FAQ: Graceful Shutdown in Containers: Key Takeaways

1Why does my application ignore SIGTERM in the container even though it handles it locally?
Usually it is the shell form in CMD, which inserts an extra shell layer as PID 1 that does not forward signals, or the fact that PID 1 without a registered handler treats signals fundamentally differently from ordinary child processes.
2What does exit code 137 mean for a Docker container?
137 results from 128 plus signal number 9, that is, SIGKILL. It indicates that the container did not respond cleanly to SIGTERM and Docker terminated it forcefully once the grace period expired.
3How long does docker stop wait by default before sending SIGKILL?
The default is ten seconds. It can be adjusted with docker stop -t for a single call, or permanently via stop_grace_period in the Compose file.
4Do I need to write my own SIGTERM handler in Node.js?
Yes, Node.js does not register a sensible SIGTERM handler on its own. Without a custom process.on('SIGTERM') callback, the Node process does not shut down open requests cleanly and simply gets hard killed once the grace period expires.
5Does PHP-FPM automatically behave correctly when it receives SIGTERM?
The PHP-FPM master process already handles SIGTERM sensibly, provided it runs in the foreground with --nodaemonize as PID 1. It stops accepting new requests and waits for running workers to finish before terminating.
6What is the difference between CMD in shell form and exec form?
Shell form, such as CMD node server.js, is internally executed as /bin/sh -c, which makes the shell PID 1. Exec form, written as a JSON array like CMD ["node", "server.js"], starts the application directly as PID 1 with no enclosing shell.
7How can I test whether my graceful shutdown actually works?
The easiest way is docker stop while tailing the container's logs: if the expected log lines like received SIGTERM and closing connections appear, the handler works. If they never appear, the problem usually lies in PID 1 signal handling or the shell form in CMD.
8Is a try-finally block in PHP enough to close database connections on shutdown?
For classic PHP-FPM with one request process per call, request local cleanup is usually sufficient, since each worker ends after the request anyway. For long running PHP processes with pcntl signal handling, closing must happen explicitly in the SIGTERM callback, a try-finally alone does not react to signals.
9Why should a load balancer know about a shutdown before SIGTERM is even sent?
Because otherwise new requests can still reach the container while it is already shutting down. A readiness check that fails shortly before the stop takes the container out of rotation in time, before the actual shutdown even begins.
10Can too long a grace period cause problems?
Yes, it needlessly slows down deployments and rolling updates, because every container replacement then takes as long as the configured wait time, even when the application finished shutting down long ago and is simply idling while waiting for SIGKILL.