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:
FROM node:20-alpineWORKDIR /appCOPY . .RUN npm ci --omit=devCMD ["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:
# Start a container with the shell wrapperdocker 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 ittime docker stop signal-testThat 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:
# 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.
FROM node:20-alpineRUN apk add --no-cache tiniWORKDIR /appCOPY . .RUN npm ci --omit=devENTRYPOINT ["/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:
- tini receives it
- tini forwards it to its child process group
- Node gets SIGTERM, runs its cleanup handler, exits 0
- tini reaps it and exits cleanly
docker run -d --name tini-test my-node-apptime docker stop tini-test# ~0.5 seconds. Done.Note the JSON array form in ENTRYPOINT. This matters. If you write:
# DON'T do thisENTRYPOINT tini -- node server.jsDocker 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.
# Check if your base image already has tinidocker run --rm node:20-alpine which tini# /usr/local/bin/tini — it's already thereIf 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.
FROM python:3.12-slimRUN pip install dumb-initWORKDIR /appCOPY . .ENTRYPOINT ["dumb-init", "--"]CMD ["python", "-m", "gunicorn", "app:app"]You can tune the signal-rewriting behavior:
# Rewrite SIGTERM (15) to SIGQUIT (3) — gunicorn graceful shutdown# dumb-init --rewrite 15:3 -- your-appSIGQUIT 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 treesHonest 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.
# CLI — tini injected automaticallydocker run --init my-node-appservices: app: image: my-node-app init: trueThat’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:
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:
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:
FROM node:20-alpineWORKDIR /appCOPY server.js .# Shell wrapper — SIGTERM goes to sh, Node never sees itCMD ["sh", "-c", "node server.js"]FROM node:20-alpineWORKDIR /appCOPY server.js .# tini as PID 1 — SIGTERM forwarded to Node correctlyENTRYPOINT ["/usr/local/bin/tini", "--"]CMD ["node", "server.js"]FROM node:20-alpineWORKDIR /appCOPY server.js .# Node as PID 1 directly — works if Node handles signals correctlyENTRYPOINT ["node", "server.js"]Test shutdown timing:
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-badtime docker stop bad
# tini — expect <1 seconddocker run -d --name good test-tinitime docker stop goodThe 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.
FROM node:20-alpineWORKDIR /appCOPY . .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:
- Node with
npm start(npm wraps node, npm becomes PID 1) - Python via
python -m gunicorn(check whether the shell is in the way) - Ruby with
bundle exec - Java with wrapper start scripts
- Anything where your CMD looks like something you’d type in a shell interactively
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.