Skip to content
Go back

tini vs dumb-init vs --init

By SumGuy 11 min read
tini vs dumb-init vs --init

Your Container Has a PID 1 Problem and You Don’t Know It Yet

It’s 2 AM. Your deploy script is hanging. docker stop sent SIGTERM 30 seconds ago. The container is still sitting there, alive, doing nothing useful, and Docker just got bored and sent SIGKILL. Your app never had a chance to flush its write buffer.

Congratulations. You’ve met the PID 1 problem.

Here’s the short version: Linux expects PID 1 to do two specific things — reap orphaned child processes and forward signals to its children. Your app was never designed to be PID 1. Neither was sh. This mismatch is responsible for zombie process accumulation, graceless shutdowns, and the kind of data corruption that makes you question your career choices.

The good news: there are exactly three tools that fix this, and picking the wrong one mostly doesn’t matter.


What Actually Goes Wrong

The Shell Wrapper Trap

Look at this and tell me you haven’t written something exactly like it:

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["sh", "-c", "node server.js"]

That sh is now PID 1 inside your container. Node is a child of sh, running at PID 2 or wherever the kernel felt like putting it.

When Docker does docker stop, it sends SIGTERM to PID 1. That’s sh. Bash and sh handle SIGTERM by… doing nothing. They’re shells. They wait for their current command to finish. They don’t forward signals to their children. So Node never gets SIGTERM. It never shuts down gracefully. Docker waits 10 seconds (default grace period), gives up, and sends SIGKILL.

You can prove it yourself:

Terminal window
# Start a container with the shell wrapper
docker run -d --name signal-test node:20-alpine \
sh -c 'node -e "process.on(\"SIGTERM\", () => { console.log(\"got SIGTERM\"); process.exit(0); }); require(\"http\").createServer().listen(3000)"'
# Send stop and time it
time docker stop signal-test

That time output is going to say somewhere around 10 seconds. The Node process never received anything.

The Zombie Process Problem

The second issue is subtler. When a process forks a child and the child exits, the child becomes a zombie — dead, but its entry stays in the process table until the parent calls wait() to collect the exit status.

In a normal Linux system, if a parent dies before collecting its child, the child gets reparented to PID 1. Init then calls wait() on it automatically. That’s basic init hygiene.

Your Node app or Python service is not init. It’s not calling wait() on processes it didn’t fork directly, and it’s definitely not calling wait() on orphaned strangers. So if you’re running anything inside a container that forks — spawn(), exec(), background processes, health check scripts — zombies accumulate over time.

You can watch this happen:

Terminal window
# Inside a container running a pid1-naive app that forks:
ps aux | grep " Z"
# Z state = zombie. Entries here mean you have the problem.

Individually they’re harmless. At scale, with enough containers running long enough, you start running out of PIDs. Fun times.


tini: The OG Fix

tini is a ~150-line C program that does exactly two things: reaps zombies and forwards signals. That’s it. No service manager, no supervision tree, no YAML config. Just the two things PID 1 is supposed to do.

Dockerfile
FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

The -- is important — it tells tini “everything after this is the command, stop parsing flags.” Now tini is PID 1, Node is PID 2, and when Docker sends SIGTERM:

  1. tini receives it
  2. tini forwards it to its child process group
  3. Node gets SIGTERM, runs its cleanup handler, exits 0
  4. tini reaps it and exits cleanly
Terminal window
docker run -d --name tini-test my-node-app
time docker stop tini-test
# ~0.5 seconds. Done.

Note the JSON array form in ENTRYPOINT. This matters. If you write:

Dockerfile
# DON'T do this
ENTRYPOINT tini -- node server.js

Docker runs that as a shell command. You just put sh back as PID 1 and put tini as PID 2. You’ve gone in a circle.

Always use JSON array form for ENTRYPOINT when you care about signals.

tini in the wild

Alpine ships it. Most distro-based images have it available. Node official images actually include it — it’s at /usr/bin/tini or /sbin/tini depending on the base.

Terminal window
# Check if your base image already has tini
docker run --rm node:20-alpine which tini
# /usr/local/bin/tini — it's already there

If it’s already present, you don’t need the apk add.


dumb-init: Yelp’s Take

dumb-init came from Yelp and does the same core job as tini with one meaningful difference: signal forwarding to the entire process group, not just the direct child.

The difference matters when your app spawns a process tree. With tini, if your Node server forks worker processes, tini sends SIGTERM to Node. Node has to handle propagating it down to its workers. If Node is poorly written and doesn’t do that, those workers are orphaned on shutdown.

dumb-init by default uses the process group — it sends the signal to every process that shares the same process group as the main child. More aggressive. More thorough. Also occasionally surprising if you’re not expecting it.

Dockerfile
FROM python:3.12-slim
RUN pip install dumb-init
WORKDIR /app
COPY . .
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "-m", "gunicorn", "app:app"]

You can tune the signal-rewriting behavior:

Terminal window
# Rewrite SIGTERM (15) to SIGQUIT (3) — gunicorn graceful shutdown
# dumb-init --rewrite 15:3 -- your-app

SIGQUIT tells gunicorn to do a graceful shutdown instead of an immediate stop. Whether you need this depends entirely on your app’s signal handling.

tini vs dumb-init: the actual diff

tini:
Simpler, predictable behavior
Built into Docker engine (used by --init flag)
Forwards to direct child only by default
No signal rewriting
dumb-init:
Process group forwarding by default
Signal rewriting flag (--rewrite)
pip-installable
Separate install, not built into Docker
Slightly more surprising with complex process trees

Honest assessment: for 90% of use cases they’re identical. The process-group forwarding in dumb-init only matters if your app forks workers AND doesn’t handle signal propagation internally. Pick based on what’s already in your base image or what your team already uses.


docker —init: Zero Dockerfile Change

This is the one most people should probably be using.

Docker has shipped tini built in since version 1.13. When you pass --init to docker run, Docker injects tini as PID 1 transparently, without any Dockerfile changes.

Terminal window
# CLI — tini injected automatically
docker run --init my-node-app
compose.yaml
services:
app:
image: my-node-app
init: true

That’s it. No Dockerfile changes. No tini install. Your CMD or ENTRYPOINT still runs — tini just wraps it automatically.

Under the hood, Docker is using the tini binary that ships with the engine, typically at /usr/bin/docker-init. You can verify it’s working:

Terminal window
docker run --init --rm node:20-alpine ps aux
# PID 1: /sbin/docker-init -- node ...
# PID 7: node ...

The k8s parity caveat

Here’s why you might still want explicit tini in the Dockerfile even if --init works fine in Docker: Kubernetes doesn’t have --init. When you take your image to k8s, the init behavior is gone unless it’s baked in.

If you’re writing an image that might run in both environments, add tini explicitly in the Dockerfile. If you’re sure it’ll only ever run under Docker Compose or direct Docker, init: true is fine and keeps your Dockerfile cleaner.


A Real Signal Demo

Here’s a minimal Node server with a graceful shutdown handler. Save it as server.js:

server.js
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('ok\n');
});
server.listen(3000, () => console.log('listening on 3000'));
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
});

Now compare these three Dockerfiles side by side:

Dockerfile.bad
FROM node:20-alpine
WORKDIR /app
COPY server.js .
# Shell wrapper — SIGTERM goes to sh, Node never sees it
CMD ["sh", "-c", "node server.js"]
Dockerfile.tini
FROM node:20-alpine
WORKDIR /app
COPY server.js .
# tini as PID 1 — SIGTERM forwarded to Node correctly
ENTRYPOINT ["/usr/local/bin/tini", "--"]
CMD ["node", "server.js"]
Dockerfile.direct
FROM node:20-alpine
WORKDIR /app
COPY server.js .
# Node as PID 1 directly — works if Node handles signals correctly
ENTRYPOINT ["node", "server.js"]

Test shutdown timing:

Terminal window
docker build -f Dockerfile.bad -t test-bad .
docker build -f Dockerfile.tini -t test-tini .
# Bad — expect ~10 seconds (SIGKILL timeout)
docker run -d --name bad test-bad
time docker stop bad
# tini — expect <1 second
docker run -d --name good test-tini
time docker stop good

The difference is visceral. One exits in milliseconds. The other sits there for 10 seconds like it’s considering your feelings. It is not.


When You Don’t Need Any of This

Your app is PID 1 directly (JSON array ENTRYPOINT, no shell wrapper), it handles SIGTERM correctly, and it doesn’t fork unrelated child processes that need zombie reaping.

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
# Node is PID 1. Node handles SIGTERM. No init wrapper needed.
ENTRYPOINT ["node", "server.js"]

Go binaries often fall into this category. A statically compiled Go HTTP server that calls signal.Notify correctly doesn’t need a wrapper. It IS its own init for the purposes of signal handling, and it typically doesn’t fork unrelated child processes.

Where you’re most likely to get burned:

The tell: does your ENTRYPOINT or CMD start with a shell command or a script that itself launches the real app? If yes, you probably want an init wrapper.


Multi-Process Containers: A Different Beast

If you’re running multiple services in one container — nginx + app + cron, or the classic “I know I shouldn’t but here we are” setup — you need a proper process supervisor, not just tini.

s6-overlay is the current standard for this. It’s what linuxserver.io uses across their entire image catalog. It provides a full supervision tree with dependency ordering and clean shutdown sequencing.

supervisord is the older Python-based option. More familiar to people coming from traditional server backgrounds. Works fine, just heavier.

tini and dumb-init are not supervisors. They keep the lights on for a single process tree. If your container needs to run parallel services, use something designed for that job. But also, seriously consider whether you actually need multiple processes in one container. The answer is usually no.


The Verdict

For 99% of containers: init: true in your Compose file or --init on docker run. Zero Dockerfile changes, works today, handled correctly by Docker’s bundled tini. Done.

If your image needs to work in Kubernetes or anywhere Docker isn’t managing the init injection: Add tini explicitly in the Dockerfile. apk add tini on Alpine, done.

If you need process-group signal forwarding or signal rewriting: dumb-init. This is a minority use case — gunicorn with SIGQUIT graceful shutdown is the main one people actually hit in the wild.

If your app already handles signals correctly and runs as PID 1 directly: Nothing. You’re fine. Go to sleep.

The real takeaway isn’t about which init tool is best. It’s that CMD ["sh", "-c", "your-app"] is a trap that millions of Dockerfiles have fallen into, it adds 10 seconds to every graceful shutdown, and it takes about 30 seconds to fix. Pick your preferred init wrapper, make it a team habit, move on.

Your 2 AM self will be significantly less miserable for it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Glances vs Netdata: Two Free-Tier Monitors Compared
Next Post
Container Escape: How to Stop It

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts