en

dumb-init vs tini: What Docker Init to Use in 2026

Both tini and dumb-init are feature-complete, stable, and solve the same well-defined problem. Pick dumb-init for signal rewriting, tini for Docker's native --init consistency, or catatonit for the most actively maintained option.

Table of Contents


Why Does This Matter? The PID 1 Problem

In every Linux system, Process ID 1 has special kernel treatment. On a regular host, PID 1 is systemd or SysV init. In a container, whatever runs first — usually your application — becomes PID 1.

The kernel treats PID 1 differently in two critical ways:

1. No Default Signal Handlers

For any process with PID > 1, the kernel provides built-in default signal handlers. If the process hasn’t registered a custom handler for SIGTERM, the kernel terminates it. That’s why kill <pid> works on arbitrary processes.

PID 1 gets no such defaults. If PID 1 has not explicitly registered a handler for a signal, the kernel silently discards it. SIGTERM to a naive PID 1 does nothing. SIGINT (Ctrl+C) does nothing. Only SIGKILL (signal 9) cannot be blocked or ignored.

Practical impact: When you run docker stop, Docker sends SIGTERM to PID 1. If PID 1 ignores it, Docker waits 10 seconds (the default grace period), then sends SIGKILL. Your container never shuts down gracefully. Every deployment, every scaling event, every pod termination takes 10+ extra seconds per container.

2. Zombie Process Reaping

When a process dies, it enters a “zombie” state — a <defunct> entry in the process table — until its parent calls wait() to collect its exit status. On a normal Linux system, when a parent process dies, its orphaned children are reparented to PID 1, which is expected to reap them.

In a container, your application is PID 1. Unless it explicitly calls wait() on child processes — which the vast majority of applications don’t — dead children become permanent zombies.

Practical impact: Each zombie consumes a PID slot. The Linux kernel process table is finite. In long-running containers with high process churn (health checks, workers, subprocesses), zombies accumulate until fork() calls start failing with EAGAIN. In Kubernetes, this can cascade to the node level, making it unable to start new processes for any workload. There are documented incidents of this causing node-level outages in production Kubernetes clusters.

The Classic npm Trap

One of the most common real-world manifestations:

# BAD: shell form — /bin/sh becomes PID 1, npm is a child, node is a grandchild
CMD npm start

# ALSO BAD: even exec form of npm — npm doesn't forward signals
CMD ["npm", "start"]

# CORRECT: run node directly
CMD ["node", "server.js"]

When /bin/sh -c npm start runs, the process tree is:

PID 1: /bin/sh          ← ignores SIGTERM (no handler registered)
  PID 2: npm            ← never receives SIGTERM
    PID 3: node server  ← never receives SIGTERM

SIGTERM hits /bin/sh, which ignores it as PID 1, and never reaches node. NPM’s own documentation states: “NPM is not a process manager and won’t pass any signals to your application.”


What Container Init Systems Do

Container init systems (tini, dumb-init, catatonit) are tiny programs — 10-20 KB — that run as PID 1 and do exactly two things:

  1. Forward signals — Register handlers for all catchable signals and forward them to child processes. Your app (running as PID 2+) gets proper kernel-default signal behavior.

  2. Reap zombies — Periodically call waitpid() to collect exit statuses of dead child processes, preventing zombie accumulation.

They are not process supervisors. They don’t restart crashed processes, manage dependencies, or provide logging. They are pass-through wrappers that fix Linux’s PID 1 quirks.


tini

Repositorykrallin/tini
AuthorThomas Orozco (krallin)
LanguageC (~688 lines)
LicenseMIT
Stars~10,900
Latest Releasev0.19.0 — April 19, 2020
Last CommitApril 2020
Binary Size~10 KB (dynamic), < 1 MB (static)

Status: Feature-Complete, No Activity Since 2020

The user’s observation is correct: tini has had zero commits since April 2020 — over 5 years of inactivity. Issues are still filed (most recently December 2025), but the maintainer has not responded since approximately 2021-2022.

This is not necessarily a problem. The Linux kernel APIs tini uses (sigtimedwait, waitpid, prctl) are stable and won’t change. The problem tini solves is fully defined and static. However, it does mean:

  • Bug fixes won’t be merged
  • New architecture support (riscv64) won’t happen
  • Security patches are unlikely
  • Open issues (33 as of early 2026) remain unaddressed

How It Works

  1. Parse CLI args and environment variables
  2. Block most signals with sigprocmask() (prevents races during setup)
  3. Optionally register as subreaper via prctl(PR_SET_CHILD_SUBREAPER, 1)
  4. Fork and exec the child process
  5. Main loop: call sigtimedwait() (1-second timeout) to wait for signals
    • If SIGCHLD: call waitpid(-1, &status, WNOHANG) in a loop to reap all zombies
    • If anything else: forward to child via kill(child_pid, signal)
  6. When the primary child exits, tini exits with the same code

Key Features

  • Subreaper mode (-s or TINI_SUBREAPER=1): Registers as a subreaper via prctl(), allowing it to reap orphaned processes even when not running as PID 1. Useful in shared PID namespaces.
  • Process group mode (-g or TINI_KILL_PROCESS_GROUP=1): Forward signals to the entire process group, not just the direct child.
  • Exit code remapping (-e CODE): Remap specific exit codes to 0. Common use: -e 143 for Java apps (143 = 128 + SIGTERM, which Java treats as non-zero).
  • Parent death signal (-p SIGNAL): Set a signal to send to the child if tini itself is killed.
  • Docker --init built-in: Docker bundles tini as docker-init. Running docker run --init injects tini transparently.

Default Signal Mode: Single Child

By default, tini forwards signals only to its direct child — not to grandchildren or the process group. If your entrypoint is a shell script that spawns subprocesses without exec, SIGTERM may only reach the shell, not the actual application.

Use -g to switch to process-group mode when needed.

Installation

# Alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["your-app"]

# Debian/Ubuntu
RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["your-app"]

# Distroless / scratch — use static binary
COPY --from=krallin/tini:latest /tini /tini
ENTRYPOINT ["/tini", "--"]
CMD ["your-app"]

Known Limitations

  • No signal rewriting — Cannot translate SIGTERM to SIGQUIT (or any other mapping). You need a wrapper script or dumb-init for this.
  • No signal dropping — Cannot selectively ignore specific signals.
  • Single child only — Not a process supervisor. Cannot manage multiple services.
  • Linux only — Uses Linux-specific syscalls. No FreeBSD, no Windows.
  • Requires -- separator — When passing arguments that could be misinterpreted as tini flags.

Notable Users

  • Jenkins (jenkins/jenkins) — ENTRYPOINT ["/bin/tini", "--", "/usr/local/bin/jenkins.sh"]
  • Jupyter (jupyter/base-notebook) — tini -g -- start-notebook.sh
  • Kibana / Elastic Beats — Embedded in official Docker images
  • Docker itself — Bundled as the --init binary since Docker 1.13 (January 2017)

dumb-init

RepositoryYelp/dumb-init
AuthorYelp (Chris Kuehl)
LanguageC (single source file)
LicenseMIT
Stars~7,300
Latest Releasev1.2.5 — February 2, 2023
Last Meaningful Commit~Late 2022 (automated pre-commit bot activity through early 2025)
Binary Size~20 KB (musl/Alpine), ~700 KB (glibc static)

Status: Feature-Complete, Low Activity

dumb-init’s last release (v1.2.5) was February 2023. There are 48+ commits on master since that release, but no new tag has been cut. The Alpine package was rebuilt as recently as February 2026 (v1.2.5-r4). Open issues (20) and PRs (10) receive slow responses.

Like tini, dumb-init is in a “done” state — the problem it solves hasn’t changed, and the code is stable.

How It Works

  1. Fork once: parent becomes PID 1 (dumb-init), child runs your command
  2. Parent registers signal handlers for every catchable signal
  3. Default: create new session via setsid() — child becomes session leader
  4. On signal: forward to the entire process group via kill(-child_pgid, signal)
  5. On SIGCHLD: call waitpid(-1, &status, WNOHANG) in a loop to reap all zombies
  6. When primary child exits, send SIGTERM to remaining children, then exit

Key Features

  • Signal rewriting (--rewrite FROM:TO): Translate one signal to another before forwarding. This is dumb-init’s killer feature that tini lacks.
    # nginx needs SIGQUIT for graceful shutdown, but orchestrators send SIGTERM
    ENTRYPOINT ["/usr/bin/dumb-init", "--rewrite", "15:3", "--"]
    CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
  • Signal dropping: Rewrite to signal 0 to silently ignore a specific signal.
  • Session leader mode (default): Signals reach the entire process group, not just the direct child. This is the opposite default from tini and is safer for shell-script entrypoints.
  • Single-child mode (--single-child or DUMB_INIT_SETSID=0): Only signal the direct child, not the process group.
  • Job control support (v1.2.0+): Properly transfers the controlling terminal to the child, enabling Ctrl+Z in interactive shells.

Default Signal Mode: Process Group (setsid)

This is the most important practical difference from tini. By default, dumb-init creates a new session and forwards signals to the entire process group. If your ENTRYPOINT is dumb-init -- /bin/sh start.sh and start.sh spawns java -jar app.jar without exec, SIGTERM will reach all processes in the group, including the JVM.

With tini’s default (single-child mode), only the shell would receive SIGTERM.

Installation

# Alpine
RUN apk add --no-cache dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["your-app"]

# Debian/Ubuntu
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["your-app"]

# Python images (PyPI wheel contains the C binary)
RUN pip install dumb-init
ENTRYPOINT ["dumb-init", "--"]

# Direct binary download
RUN wget -O /usr/local/bin/dumb-init \
    https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 \
    && chmod +x /usr/local/bin/dumb-init

Known Limitations

  • No subreaper support — Cannot register as a child subreaper via prctl(). If dumb-init is not PID 1, orphaned grandchild processes are reparented to the actual PID 1, not to dumb-init. This matters in shared PID namespaces.
  • No exit code remapping — Cannot translate exit code 143 to 0 (tini’s -e flag).
  • Signal numbers only in --rewrite — No symbolic names like SIGTERM:SIGQUIT.
  • Single child only — Not a process supervisor.
  • Linux only — Does not work on macOS (PyPI install will fail).
  • macOS pip install fails — The C binary is Linux-only; pip on macOS has no wheel.

Notable Users

  • Apache Airflow (apache/airflow) — Ships dumb-init in the official Docker image. Documents DUMB_INIT_SETSID behavior for Celery workers.
  • GitLab — Uses dumb-init for Docker-based analyzers (SAST, dependency scanning).
  • Yelp — The origin company, running it across all containerized services.
  • 511+ GitHub repositories depend on it directly.

Head-to-Head Comparison

Featuretinidumb-init
Default signal modeSingle child onlyEntire process group (setsid)
Process group mode-g flag to enableDefault behavior
Single-child modeDefault behavior--single-child to enable
Signal rewritingNoYes (--rewrite 15:3)
Signal droppingNoYes (rewrite to 0)
Subreaper supportYes (-s)No
Exit code remappingYes (-e 143)No
Parent death signalYes (-p)No
Bundled in DockerYes (since Docker 1.13)No
Binary size (dynamic)~10 KB~20 KB (musl)
PyPI distributionNoYes
Last releaseApril 2020 (v0.19.0)February 2023 (v1.2.5)
MaintenanceZero commits since 2020Bot commits only since late 2022
Stars~10,900~7,300

The Default Mode Difference Matters

This is the single most impactful practical difference.

Scenario: Your Dockerfile uses a shell script as entrypoint:

ENTRYPOINT ["init-system", "--", "/entrypoint.sh"]
CMD ["java", "-jar", "app.jar"]

And /entrypoint.sh does some setup before running the app:

#!/bin/sh
export DATABASE_URL="..."
# Forgot to use exec here!
"$@"

Because there’s no exec, the process tree is:

PID 1: init-system
  PID 2: /bin/sh /entrypoint.sh
    PID 3: java -jar app.jar
  • With dumb-init (default): SIGTERM reaches the entire process group → both the shell and Java receive it → graceful shutdown works.
  • With tini (default): SIGTERM reaches only PID 2 (the shell) → the shell exits → Java may or may not receive SIGTERM depending on shell behavior → often results in a 10-second timeout followed by SIGKILL.

You can fix this with exec "$@" in the script, or by using tini with -g. But dumb-init’s default is more forgiving of common mistakes.


Other Alternatives

catatonit — The Actively Maintained Option

RepositoryopenSUSE/catatonit
Stars~254
Latest Releasev0.2.1 — December 14, 2024
LicenseGPL-2.0+
Used ByPodman (as its --init binary)

catatonit was created by the openSUSE team because they found that both tini and dumb-init use sigtimedwait(2) for signal handling, which involves periodic polling. catatonit uses signalfd(2) — a Linux-native event-driven signal file descriptor — which the authors believe provides better stability. Patching tini or dumb-init to use signalfd “would have been closer to a full rewrite,” so they started fresh.

Why it matters: catatonit is the most actively maintained container init system as of 2026. It had commits as recently as December 2024 and cuts regular releases. If the “no commits since 2020” concern about tini is a dealbreaker, catatonit is the modern alternative.

Caveat: It has fewer features than either tini or dumb-init — no signal rewriting, no exit code remapping. It deliberately stays minimal, implementing only the core docker-init use case.

s6-overlay — For Multi-Process Containers

Repositoryjust-containers/s6-overlay
Stars~4,400
Latest Version3.2.x (actively maintained)

s6-overlay is in a completely different category. It’s a full process supervision suite:

  • Supervises multiple services (not just one child)
  • Service dependencies and startup ordering
  • Init scripts (/etc/cont-init.d/) and finalization scripts (/etc/cont-finish.d/)
  • Built-in log rotation via s6-log
  • Graceful shutdown sequencing

Use s6-overlay when you genuinely need to run multiple processes in one container (nginx + php-fpm, app + sidecar). Don’t use it for single-process containers — it’s overkill.

Heavily used by LinuxServer.io images.

Other Options (Less Common)

InitNotes
supervisordPython-based. Requires Python runtime in the image. Configuration-heavy. Not container-native.
pid1 (FPComplete)Haskell/Rust implementation. Niche. Primarily for the Haskell ecosystem.
runitTraditional UNIX init. More complexity than needed for containers.
systemdTechnically possible but strongly discouraged. Requires cgroup access, D-Bus, significant attack surface.

Kubernetes Considerations

Kubernetes Has No --init Equivalent

Docker’s --init flag is a Docker-only feature. Kubernetes does not support it. There is an open issue since 2019 (kubernetes/kubernetes#84210) requesting native support, but it remains unresolved as of 2026.

In Kubernetes, you must embed your init system in the container image. You cannot rely on runtime injection.

# This is the Kubernetes-compatible approach
FROM alpine:3.20
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["your-app"]

Pod Termination Lifecycle

When Kubernetes terminates a pod:

  1. Pod is marked “Terminating” — removed from service endpoints
  2. preStop hook executes (if defined)
  3. SIGTERM is sent to PID 1 of each container
  4. terminationGracePeriodSeconds countdown begins (default: 30s)
  5. After grace period: SIGKILL

If PID 1 doesn’t handle SIGTERM → the full 30-second grace period is wasted → then SIGKILL. At scale with frequent deployments, this adds up significantly.

The shareProcessNamespace Alternative

Kubernetes offers a built-in alternative to embedding an init:

spec:
  shareProcessNamespace: true
  containers:
  - name: app
    image: myapp

When enabled, all containers in the pod share a PID namespace. The Kubernetes pause container becomes PID 1 for the entire pod and handles zombie reaping. However:

  • Signal handling still needs to work within each container
  • This is opt-in (off by default since Kubernetes 1.8)
  • Your app is no longer PID 1, which can affect tools that check /proc/1/cmdline

Important: Don’t Confuse Kubernetes initContainers

Kubernetes initContainers are not PID 1 init processes. They are user-defined setup containers that run sequentially before the main application containers, then exit. They solve “run database migrations before the app starts” problems, not signal handling.


Cloud Provider Support

AWS ECS

ECS has native init support via the initProcessEnabled flag:

{
  "containerDefinitions": [{
    "linuxParameters": {
      "initProcessEnabled": true
    }
  }]
}

This injects tini automatically. AWS specifically recommends it for ECS Exec (interactive shell) workloads and zombie reaping.

Google Cloud Run

  • Gen 1: Your code does NOT run as PID 1 — Cloud Run wraps it. The init problem is partially abstracted away.
  • Gen 2: Your code IS PID 1. Google recommends installing a SIGTERM handler. Using tini/dumb-init is a good practice here.

Azure Container Apps / Container Instances

No built-in init process support. Azure sends SIGTERM before termination and expects your application to handle it. Standard Docker best practices apply: embed tini in your image or handle signals in your app.

Podman

Podman bundles catatonit as its --init binary. podman run --init uses catatonit automatically. You can override with --init-path.


Do You Even Need an Init?

The overhead is so low (~10-20 KB, zero CPU) that there’s rarely a reason to skip it. But technically, you don’t need one when all of these are true:

  1. Single process — Your container runs exactly one process, no children
  2. Exec form — You use CMD ["node", "server.js"], not CMD node server.js
  3. Explicit signal handling — Your app registers a SIGTERM handler
  4. No subprocesses — No health check scripts, no worker spawning, no shell wrappers

Language-Specific Notes

Go: Programs handle SIGTERM by default in recent versions, but behavior as PID 1 can be inconsistent. Best practice: always register explicit handlers with signal.Notify().

c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
go func() {
    <-c
    // cleanup...
    os.Exit(0)
}()

Rust: Similar to Go — use the signal-hook or tokio::signal crate for explicit handling.

Node.js: Modern Node.js (v12+) handles SIGTERM more robustly than older versions, but only when running directly (not via npm). If using CMD ["node", "server.js"] and handling SIGTERM in code, you can skip an init.

Python: Does not handle SIGTERM by default. Either use an init or register a handler via the signal module.

Java/JVM: The JVM registers SIGTERM handlers via shutdown hooks (Runtime.addShutdownHook()), but behavior as PID 1 can be unreliable. JVM apps are strong candidates for always using an init.

The Pragmatic Answer

Adding tini costs 10 KB and zero effort. Skipping it saves 10 KB and requires you to prove your app handles signals correctly as PID 1 in all edge cases. The risk-reward ratio strongly favors just using an init.


Performance Overhead

MetricImpact
Binary size10-20 KB (negligible)
Startup latencyMicroseconds (unmeasurable vs. container startup overhead of ~550ms)
Steady-state CPUZero (blocked in waitpid/sigtimedwait sleep)
MemoryNegligible (< 1 MB RSS)

Container startup latency is dominated by namespace creation, cgroup setup, and OverlayFS mounting. The exec() call for a tiny init binary is in the microsecond range and invisible in benchmarks.

In steady state, tini/dumb-init/catatonit sit idle, waiting for signals or child exits. They consume no CPU cycles until an event occurs — which is rare (only on process death or orchestrator signals).


Decision Matrix

ScenarioRecommendation
Simple single-process Docker containerdocker run --init (tini) or embed tini
Kubernetes deploymentEmbed tini or dumb-init in your Dockerfile
Shell script as entrypointdumb-init (process-group default is safer)
Need to remap signals (SIGTERM → SIGQUIT)dumb-init with --rewrite
Need subreaper mode (non-PID-1 init)tini with -s
Java app with exit code 143 issuetini with -e 143
Podman containerscatatonit (built-in via --init)
Multi-process containers6-overlay
Distroless / scratch imagetini-static or catatonit static
Want the most actively maintained optioncatatonit (last release Dec 2024)
AWS ECSSet initProcessEnabled: true in task definition
Google Cloud Run gen2Embed tini or handle SIGTERM in app
Apache Airflowdumb-init (already shipped in official image)
Python-heavy imagedumb-init (available via pip install dumb-init)
Want Docker --init consistencytini (it’s what Docker bundles)

Recommendations

For Most People: tini

tini is the industry default. Docker bundles it. Jenkins, Jupyter, and Elastic use it. It’s the smallest binary. It has subreaper support for advanced scenarios. Yes, it hasn’t had a commit since 2020 — but the problem it solves hasn’t changed since 2020 either.

FROM alpine:3.20
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["your-app"]

For Shell-Script Entrypoints or Signal Rewriting: dumb-init

If your entrypoint is a shell script that doesn’t exec, dumb-init’s process-group default is more forgiving. If you need to translate SIGTERM to a different signal for graceful shutdown (nginx, apache, custom apps), dumb-init is the only option without a wrapper script.

FROM alpine:3.20
RUN apk add --no-cache dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--rewrite", "15:3", "--"]
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

For Those Concerned About Maintenance: catatonit

If the stale maintenance of tini (2020) and dumb-init (2022-2023) concerns you, catatonit is the most actively maintained container init system. It’s what Podman uses, it’s backed by the openSUSE project, and it had releases in December 2024. It lacks signal rewriting and exit code remapping, but covers the core use case.

The Non-Negotiable Rule

Regardless of which init you choose (or if you choose none), always use exec form in your Dockerfile:

# ALWAYS this (exec form)
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

# NEVER this (shell form)
ENTRYPOINT "dumb-init -- node server.js"
CMD "node server.js"

And if you use wrapper scripts, always end with exec:

#!/bin/sh
# setup...
exec "$@"  # replaces the shell with your process

About “No Commits Since 2020” — Is That a Problem?

This is a nuanced question. In the world of web frameworks and libraries, years without commits usually signal abandonment. But tini and dumb-init are closer to coreutils than to React — they solve a tiny, well-defined, stable kernel behavior that hasn’t changed since Linux 2.6.

Consider: when was the last time /bin/true needed an update?

The real risks of unmaintained init systems are:

  • Security vulnerabilities in the C code going unpatched (low probability — the code is ~700 lines)
  • New architecture support not being added (riscv64 has been requested for tini since August 2025; no response)
  • Ecosystem rot — build tooling, CI, package metadata becoming stale

These are real but low-severity concerns. For most production workloads, tini v0.19.0 and dumb-init v1.2.5 will continue working indefinitely. If these concerns matter to you, catatonit is the actively maintained alternative.


Sources

Primary Projects

PID 1 Problem Deep Dives

Comparisons and Best Practices

Kubernetes

Cloud Providers

Real-World Incidents

Linux Kernel

Docker