docker

Docker Graceful Shutdown: Signals, PID 1 and tini Explained

By Shubhankar Tripathi • • 5 min read

Graceful Shutdown in Docker: Signals, PID 1 and tini

Have you noticed that some containers stop instantly while others take exactly ten seconds and then exit with code 137? That ten-second pause is not a coincidence. It is the sign of a container that never received, or never handled, the shutdown signal Docker sent it, and was eventually killed by force.

A forced kill is more than an annoyance. In-flight HTTP requests are dropped, database transactions are cut off, queue messages are lost or processed twice, and temporary files are left behind. Every deployment, scale-down and restart becomes a small outage.

This guide explains how Docker stops containers, why the process running as PID 1 behaves differently from every other process, and how to fix shutdown problems using the exec form, exec in entrypoint scripts, tini and the --init flag, and proper signal handling in your application. All examples are tested against Docker Engine 29 and Docker Compose v5, as of October 2026.

Quick Answer

  • docker stop sends SIGTERM to the container's PID 1, waits 10 seconds, then sends SIGKILL.

  • A container takes 10 seconds to stop when PID 1 ignores SIGTERM. This usually happens because PID 1 is a shell (from the shell form of CMD or ENTRYPOINT) or an application with no SIGTERM handler.

  • Fix it by using the exec form (CMD ["node", "server.js"]), using exec "$@" in entrypoint scripts, running an init process with docker run --init or tini, and handling SIGTERM in your application.

  • An exit code of 143 means the container stopped gracefully on SIGTERM; 137 means it was killed with SIGKILL.

How Docker Stops a Container

When you run docker stop, docker compose down, or when an orchestrator replaces a container, Docker follows the same sequence:

  1. Docker sends the container's stop signal to the main process (PID 1 inside the container). The default stop signal is SIGTERM.

  2. Docker waits for the grace period. The default is 10 seconds for Linux containers.

  3. If the process is still running when the grace period ends, Docker sends SIGKILL, which the kernel enforces immediately. The process cannot catch, block or ignore it.

Only PID 1 receives the signal. Other processes in the container are not signalled directly by Docker. When PID 1 exits, the kernel kills every remaining process in the container's PID namespace.

Command

Signal sent

Grace period

docker stop <container>

Stop signal (default SIGTERM), then SIGKILL

10 seconds (change with -t)

docker stop -t 30 <container>

Stop signal, then SIGKILL

30 seconds

docker kill <container>

SIGKILL immediately

None

docker kill -s SIGTERM <container>

Only SIGTERM (no follow-up SIGKILL)

Not applicable

docker compose down / stop

Stop signal, then SIGKILL

10 seconds (change with stopgraceperiod)

docker restart <container>

Same as docker stop, then start

10 seconds (change with -t)


Linux Signals You Need to Know

Signal

Number

Can be caught?

Typical meaning in containers

SIGTERM

15

Yes

Polite request to shut down; Docker's default stop signal

SIGINT

2

Yes

Interrupt, the same as pressing Ctrl+C

SIGQUIT

3

Yes

Quit; Nginx uses it for graceful shutdown

SIGHUP

1

Yes

Often used to reload configuration

SIGKILL

9

No

Immediate, forced termination by the kernel

SIGCHLD

17

Yes

Sent to a parent process when a child exits


When a process is terminated by a signal, the shell and Docker report its exit code as 128 + signal number. This makes exit codes a quick diagnostic tool:

Exit code

Calculation

What it tells you

0

Normal exit

The application handled SIGTERM and exited cleanly with status 0

143

128 + 15 (SIGTERM)

The process was terminated by SIGTERM, usually a graceful stop

137

128 + 9 (SIGKILL)

The process was killed: the stop timeout expired, docker kill was used, or it ran out of memory

130

128 + 2 (SIGINT)

The process was interrupted with Ctrl+C or SIGINT


If docker inspect -f "{{.State.OOMKilled}}" <container> prints false and the exit code is 137 right after a docker stop, the container ignored SIGTERM and was killed when the grace period ran out.

Why PID 1 Is Special

Inside a container, your main process runs in its own PID namespace, where it has process ID 1. On a normal Linux system, PID 1 is the init system (such as systemd), and the kernel gives it two special properties. Your application inherits both of them, usually without being designed for them.

Special Property 1: Default Signal Actions Do Not Apply

For an ordinary process, if it has not installed a handler for SIGTERM, the kernel applies the default action, which is to terminate the process. For PID 1, the kernel does not apply default actions. A signal sent to PID 1 from inside its namespace (or forwarded by Docker) has an effect only if the process has explicitly installed a handler for it.

This protects a real init system from being killed accidentally, but it means that an application that never registers a SIGTERM handler, a perfectly normal choice outside containers, simply ignores docker stop. Docker waits 10 seconds and then uses SIGKILL, which the kernel always enforces.

Special Property 2: PID 1 Must Reap Zombie Processes

When a process exits, it becomes a zombie until its parent reads its exit status with a wait() system call. If a parent exits first, its children become orphans and are adopted by PID 1. A real init system continually reaps these orphans when they exit.

Most applications never expect to adopt other processes. If your application is PID 1 and it starts child processes that leave orphans behind (for example by running shell scripts, health-check helpers or headless browsers), those orphans turn into zombies that are never reaped. Zombies use no memory or CPU, but each one holds an entry in the process table. Over a long run they can exhaust the container's PID limit, after which no new processes can start.

Demo: See the 10-Second Problem for Yourself

The sleep command does not install a SIGTERM handler. Run it as PID 1 and time how long it takes to stop:

docker run -d --name no-init alpine sleep 1000

time docker stop no-init

docker inspect -f "{{.State.ExitCode}}" no-init

The stop takes about 10 seconds and the exit code is 137: SIGTERM was ignored, and the container was killed with SIGKILL.

Now run the same command with Docker's built-in init process:

docker run -d --name with-init --init alpine sleep 1000

time docker stop with-init

docker inspect -f "{{.State.ExitCode}}" with-init

The stop now completes in well under a second, and the exit code is 143. With --init, a tiny init process runs as PID 1 and starts sleep as an ordinary child process. The init process forwards SIGTERM to sleep, and because sleep is no longer PID 1, the default action applies and it terminates immediately.

docker rm no-init with-init

Cause 1: The Shell Form of CMD and ENTRYPOINT

The most common reason for slow shutdowns is the shell form of CMD or ENTRYPOINT in a Dockerfile:

# Shell form: Docker runs this as /bin/sh -c "node server.js"

CMD node server.js

With the shell form, Docker starts /bin/sh -c "node server.js". The shell typically becomes PID 1 and your application runs as its child. Docker's SIGTERM goes to the shell, which does not forward it to the application. Some shells replace themselves with the command when running a single simple command, but this behaviour varies between shells and disappears as soon as the command becomes more complex, so you cannot rely on it.

The fix is the exec form, a JSON array. Docker then starts your application directly as PID 1, with no shell in between:

# Exec form: node is PID 1 and receives SIGTERM directly

CMD ["node", "server.js"]

You can check what is running as PID 1 in any container:

docker exec <container> cat /proc/1/cmdline | tr "\0" " "; echo

If the output starts with /bin/sh -c, the container uses the shell form.

Form

Example

PID 1

Receives SIGTERM?

Shell form

CMD node server.js

/bin/sh (usually)

No, the shell does not forward it

Exec form

CMD ["node", "server.js"]

node

Yes, if the application handles it

Exec form + init

--init with CMD ["node", "server.js"]

Init process (tini)

Yes, forwarded by the init process


Cause 2: Entrypoint Scripts Without exec

Many images use a shell script as the entrypoint to prepare configuration before starting the application. This is fine, as long as the script replaces itself with the application at the end using exec:

#!/bin/sh

set -e

 

# Preparation steps

echo "Running migrations..."

./migrate.sh

 

# Replace this shell with the main process

exec "$@"

COPY docker-entrypoint.sh /usr/local/bin/

ENTRYPOINT ["docker-entrypoint.sh"]

CMD ["node", "server.js"]

exec "$@" replaces the shell process with the command passed as arguments (here the CMD), keeping the same PID. Without exec, the script stays as PID 1, the application runs as a child, and SIGTERM never reaches it.

Common mistake: Writing node server.js (without exec) as the last line of an entrypoint script, or ending the script with npm start. In both cases the shell remains PID 1 and the application never receives SIGTERM.

Cause 3: The Application Does Not Handle SIGTERM

Even with the exec form, an application running as PID 1 must install its own SIGTERM handler, because of the PID 1 rule described above. Many runtimes and frameworks do this for you; others need a few lines of code. A good handler stops accepting new work, finishes in-flight work within a deadline, closes connections, and then exits.

Node.js

const server = app.listen(3000);

 

process.on("SIGTERM", () => {

  console.log("SIGTERM received, closing server");

  server.close(() => process.exit(0));

  setTimeout(() => process.exit(1), 8000).unref();

});

Start Node.js directly (CMD ["node", "server.js"]) rather than through npm start. Package managers add an extra process layer and do not reliably forward signals to your application.

Python

import signal, sys

 

def handlesigterm(signum, frame):

    print("SIGTERM received, shutting down")

    # close connections, flush buffers here

    sys.exit(0)

 

signal.signal(signal.SIGTERM, handlesigterm)

Production servers such as Gunicorn and Uvicorn already handle SIGTERM gracefully, so with them you only need the exec form, for example CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"].

Go

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

defer stop()

 

go srv.ListenAndServe()

<-ctx.Done()

 

shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)

defer cancel()

srv.Shutdown(shutdownCtx)

Java and Spring Boot

The JVM runs its shutdown hooks when it receives SIGTERM, so Java applications in the exec form usually stop correctly. In Spring Boot, set server.shutdown=graceful (the default since Spring Boot 3.4) so the web server finishes active requests, and tune spring.lifecycle.timeout-per-shutdown-phase to fit inside Docker's grace period.

Rule of thumb: Make your application's own shutdown deadline shorter than Docker's grace period. In the examples above the application gives itself 8 seconds within Docker's default 10, so it can exit cleanly instead of being killed mid-shutdown.

What Is tini and Why Use It?

tini is a tiny, purpose-built init process for containers. It runs as PID 1, starts your application as its child, and does exactly two jobs:

  • Forwards signals (such as SIGTERM and SIGINT) to your application, so it shuts down as if it were not PID 1, with normal default signal actions.

  • Reaps zombie processes, so orphaned child processes never accumulate.

When your application exits, tini exits with the same exit code. tini adds almost no overhead and requires no configuration.

Option 1: docker run --init (Simplest)

Docker Engine ships with tini built in, installed as docker-init. The --init flag runs it as PID 1 without changing your image:

docker run -d --init --name api myapp:1.0

Running docker exec api ps shows /sbin/docker-init as PID 1 with your application as its child. To enable this for every container on a host, add "init": true to the daemon configuration (/etc/docker/daemon.json) and restart Docker.

Option 2: init in Docker Compose

services:

  api:

    image: myapp:1.0

    init: true

Option 3: Install tini in the Image

If you deploy to platforms that do not offer an --init option, build tini into the image so it works everywhere. On Alpine:

FROM node:24-alpine

RUN apk add --no-cache tini

WORKDIR /app

COPY . .

ENTRYPOINT ["/sbin/tini", "--"]

CMD ["node", "server.js"]

On Debian or Ubuntu based images, install the tini package with apt-get install -y --no-install-recommends tini and use ENTRYPOINT ["/usr/bin/tini", "--"]. The -- separates tini's own options from your command.

Two tini options are worth knowing:

  • -g sends signals to the child's whole process group, not only the direct child. Use it when your application starts helper processes that should also receive SIGTERM.

  • -s registers tini as a subreaper. Use it when tini is not PID 1, for example when another process is the container's entrypoint.

Alternatives to tini

dumb-init is a similar minimal init process. It is also widely used and rewrites or forwards signals in much the same way. In Kubernetes, the container runtime does not add an init process automatically, so build tini or dumb-init into the image if you need zombie reaping there.

Changing the Stop Signal and Grace Period

Some applications expect a signal other than SIGTERM for a graceful shutdown. Nginx, for example, shuts down gracefully on SIGQUIT, and the official nginx image declares this with STOPSIGNAL SIGQUIT. The official postgres image uses STOPSIGNAL SIGINT, which triggers PostgreSQL's fast shutdown mode. You can set the stop signal and grace period at several levels:

Where

Stop signal

Grace period

Dockerfile

STOPSIGNAL SIGQUIT

Not available

docker run

--stop-signal SIGQUIT

--stop-timeout 30

docker stop / docker restart

-s SIGQUIT (--signal)

-t 30 (--timeout)

Docker Compose

stopsignal: SIGQUIT

stopgraceperiod: 30s


services:

  worker:

    image: myworker:1.0

    init: true

    stopsignal: SIGTERM

    stopgraceperiod: 45s

Choose a grace period that covers your longest normal unit of work, such as the slowest request or a single queue job, plus a small margin. Very long grace periods make deployments and restarts slow, so it is usually better to break long jobs into resumable pieces.

Graceful Shutdown in Kubernetes

The same rules apply when your image runs in Kubernetes, with a few differences:

  • The kubelet sends the image's stop signal (SIGTERM by default) to the container and waits for terminationGracePeriodSeconds (default 30 seconds) before sending SIGKILL.

  • A preStop hook runs before the signal is sent, and its duration counts against the grace period. A short preStop sleep gives load balancers time to stop sending new traffic to the pod.

  • There is no equivalent of docker run --init. If your application needs an init process, build tini or dumb-init into the image.

For a deeper look at the pod side, see our tutorial Kubernetes Pod Lifecycle: From Creation to Termination.

Graceful Shutdown Checklist

Check

How to verify

CMD and ENTRYPOINT use the exec form

Inspect the Dockerfile; /proc/1/cmdline does not start with /bin/sh -c

Entrypoint scripts end with exec "$@"

Read the script; the application, not the shell, should be PID 1 or a direct child of tini

An init process runs where needed

--init, init: true in Compose, or tini in the image; PID 1 is docker-init or tini

The application handles SIGTERM

time docker stop finishes well under the grace period

Exit code is 0 or 143 after a stop

docker inspect -f "{{.State.ExitCode}}" <container>

The correct stop signal is set

docker inspect -f "{{.Config.StopSignal}}" <image>

Grace period fits the workload

Application deadline shorter than stopgraceperiod or terminationGracePeriodSeconds


Troubleshooting Shutdown Problems

Symptom

Likely cause

Fix

docker stop always takes exactly 10 seconds

PID 1 ignores SIGTERM (shell form, or no handler)

Use the exec form, add --init, handle SIGTERM

Exit code 137 after docker stop, OOMKilled=false

Grace period expired before the process exited

Fix signal handling or increase the grace period

Exit code 137 with OOMKilled=true

The container ran out of memory

Raise the memory limit or reduce memory usage

Ctrl+C does not stop docker run -it

PID 1 has no SIGINT handler

Use --init, or handle SIGINT in the application

Requests fail during deployments

Application exits immediately without finishing in-flight requests

Stop accepting new connections, drain existing ones, then exit

Many processes in Z (zombie) state

PID 1 does not reap orphaned children

Run tini or --init as PID 1

Child processes keep running after the main app stops

Signals reach only the direct child

Use tini with -g to signal the whole process group


To see zombie processes, list processes with their state inside the container. A Z in the state column marks a zombie:

docker exec <container> ps -o pid,ppid,stat,comm

Frequently Asked Questions

Why does my Docker container take 10 seconds to stop?

Because the process running as PID 1 does not handle SIGTERM. Docker waits for the default 10-second grace period and then sends SIGKILL. The usual causes are the shell form of CMD or ENTRYPOINT, an entrypoint script without exec, or an application without a SIGTERM handler.

What is the difference between exit code 137 and 143?

Exit code 143 (128 + 15) means the process was terminated by SIGTERM, which normally indicates a graceful stop. Exit code 137 (128 + 9) means it was killed by SIGKILL, because the stop timeout expired, docker kill was used, or the container ran out of memory.

Should I always use --init?

It is a safe default for most containers, and essential when the application is not designed to run as PID 1 or starts child processes. Applications that already handle signals and never create child processes work correctly without it. For portability across platforms, building tini into the image is often the most reliable choice.

Is docker-init the same as tini?

Yes. Docker Engine bundles tini and installs it as docker-init. The --init flag and init: true in Compose run this bundled binary as PID 1.

Does the exec form alone fix graceful shutdown?

It fixes signal delivery, because the signal now reaches your application. The application still needs a SIGTERM handler when it runs as PID 1. Using an init process removes that requirement, because the application is no longer PID 1.

Do official images like nginx and postgres need tini?

Generally no. Mature official images run their main process in the exec form and set the appropriate STOPSIGNAL, and the server processes handle their stop signals correctly. Adding --init does no harm, but it is mainly important for your own application images.

Conclusion and Next Steps

Graceful shutdown in Docker comes down to one question: does the signal reach a process that knows what to do with it? Use the exec form, end entrypoint scripts with exec "$@", run tini (or --init) as PID 1, handle SIGTERM in your application, and size the grace period to your workload. With these in place, your containers stop in milliseconds with exit code 0 or 143, deployments stop dropping requests, and zombie processes disappear.

Continue the Docker course with these tutorials on BitCodeMatrix:

  • Docker Commands Cheat Sheet — every lifecycle command, including docker stop, kill and inspect

  • How to Fix Docker Exit Code 137 — diagnose forced kills and out-of-memory errors

  • Container Isolation and Linux Namespaces — how the PID namespace makes your application PID 1

  • Docker Restart Policies (next in the course) — choosing between no, on-failure, always and unless-stopped