Skip to content
shellhacks.dev
Tooling

How to Use Docker as a Tool Runner for CLIs With Real Output

Use Docker as a tool runner for CLIs: run linters and converters without installing them, mount the project, avoid root-owned files and wrap commands.

6 min read
Summarize in AI
Illustration for "How to Use Docker as a Tool Runner for CLIs With Real Output": a terminal session, a directory tree, progress bars.

Installing every tool a project needs is a chore: the right Node version for one linter, a Python package for a formatter, a pinned pandoc for the docs. A container image bundles the tool with its dependencies, so one command runs the same version everywhere. It works for CI and for laptops, and it is a lightweight alternative to toolbox environments (toolbox containers). The examples assume Docker 27 on Linux.

The basic pattern

Shell
docker run --rm \
  -v "$PWD":/work -w /work \
  --user "$(id -u):$(id -g)" \
  koalaman/shellcheck:v0.10.0 scripts/*.sh

docker run --rm -v "$PWD":/data -w /data pandoc/core:3.5 README.md -o README.pdf
docker run --rm -i mikefarah/yq:4.44.3 '.services | keys' < compose.yaml
Text
In scripts/deploy.sh line 7:
rm -rf $DIR/*
       ^-- SC2115 (warning): Use "${var:?}" to ensure this never expands to /*.

The image name with an explicit tag pins the version, so the whole team and CI run the same one. The working directory is mounted at /work, and --rm deletes the container afterwards. The output shown is what ShellCheck prints for a risky line. For tools that read from standard input, pass -i and redirect, as in the yq example. Pick images from the tool's maintainers where possible and pin by digest for supply-chain care.

File ownership

Shell
docker run --rm -v "$PWD":/work -w /work alpine touch out.txt
ls -l out.txt
docker run --rm -v "$PWD":/work -w /work --user "$(id -u):$(id -g)" alpine touch out2.txt
ls -l out2.txt
Text
-rw-r--r-- 1 root  root  0 Nov  3 11:40 out.txt
-rw-r--r-- 1 alice alice 0 Nov  3 11:40 out2.txt

Containers run as root by default, so files they create in a bind mount belong to root on the host and are painful to delete. Passing your user and group IDs fixes it. Some tools need a writable home directory and fail with a different user; give them -e HOME=/tmp or a tmpfs home. Docker Desktop on macOS and Windows maps ownership differently, and the problem is less visible there. The permissions side is in bind mount permissions.

Wrap it so people use it

Shell
shellcheck() {
  docker run --rm -v "$PWD":/work -w /work --user "$(id -u):$(id -g)" koalaman/shellcheck:v0.10.0 "$@"
}

# or a script on the PATH: ~/bin/pandoc
#!/usr/bin/env bash
exec docker run --rm -i -v "$PWD":/data -w /data --user "$(id -u):$(id -g)" pandoc/core:3.5 "$@"

A function in your shell profile or an executable wrapper makes the command look native. Put project wrappers in the repository (a tools/ directory) so contributors need only Docker, and have the Makefile or CI call the same wrappers, see CI scripts in the repository. A wrapper script on the PATH also works from other scripts, while a shell function does not.

Safety and limits

Shell
docker run --rm -v "$PWD":/work:ro -w /work --network none koalaman/shellcheck:v0.10.0 script.sh

A container with your directory mounted can read, and unless read-only can modify, everything in it. Mount only the project, never your home directory or the Docker socket. Add :ro for tools that only read, and --network none for tools that need no network. Running an unknown image is running unknown code with those mounts, so use trusted images. Containers share the host kernel, so they are not a strong sandbox for hostile code.

Cost

Shell
time docker run --rm alpine true
time (for i in $(seq 1 50); do docker run --rm alpine true; done)

Starting a container takes a few hundred milliseconds on Linux, and more on Docker Desktop. For a command run once per CI job, that is nothing. For a linter invoked on every file from a loop, it is a problem: pass all files in one run. Pulling images costs time and disk on first use, see cleaning up Docker. GUI tools, hardware access and tools that rely on the host environment (SSH agent, credentials) need extra flags and are more trouble.

Pinning, updates and supply chain

An image is a dependency like any other, and it needs the same discipline. A tag such as latest changes without notice, so a pipeline that worked yesterday can fail today because the tool released a new version with a different default. Pin to a specific version, as in the examples, and for the strictest reproducibility pin by digest, which identifies the exact content. Record the pins in one place and update them on purpose, reading the release notes, so a linter upgrade that begins flagging new rules arrives in a commit you reviewed and not in a surprise failure.

Think about who publishes the image. Official images maintained by the tool's authors or by a known organisation are a better bet than a random account. Check the repository, the update history and the number of pulls, and prefer images with a build you can inspect. For anything that runs with access to your source or credentials, consider building the image yourself from a short Dockerfile you can read, based on a trusted base. Scan images with a vulnerability scanner as part of CI, as covered elsewhere on this site.

Caches, credentials and the environment

Tools that cache downloads, such as package managers and linters with rule sets, rebuild the cache on every run if the container is thrown away. Mount a cache directory from the host, or use a named volume, to keep it between runs: -v "$HOME/.cache/tool:/root/.cache". Mind ownership if you run as your own user. Credentials are the other direction: do not mount your whole home directory to give a tool access to one file. Mount the single file read-only, or pass a token through an environment variable with -e TOKEN that takes the value from the invoking shell without writing it in the command line.

Running on different platforms

On macOS and Windows, Docker runs in a virtual machine, and bind mounts pass through a translation layer. File watchers may not work, performance of many small files is lower and permissions behave differently. For heavy tools, such as a build with thousands of files, run the work inside a volume managed by Docker or keep the project in the Linux file system under WSL. On Apple Silicon, an image built for x86-64 runs under emulation and is much slower, so choose a multi-architecture image or pass the platform flag knowingly. A wrapper script can hide these differences from the people who use it.

When it earns its place

The pattern pays off when the tool is awkward to install, when versions must match across a team, or when you want to try a tool without installing it. It gains less for tools you use all day, where start-up and mounting overhead become friction. A mixed approach works well: install the daily tools natively with a version manager, and use containers for the occasional or pinned ones. Review the list of wrappers from time to time and remove those nobody uses, so the repository carries only tools that earn their keep.

Where this does not apply

For tools you use all day, a native install is faster and integrates with your shell and editor. In environments without Docker (some CI runners, locked-down servers), you need another approach: mise and asdf for versions, see mise and asdf. See the docker run reference.

The Docker documentation on the user option explains how the container user relates to host IDs, which is the key to avoiding root-owned output.

Also inDocker
Taggeddocker
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.