Quick Links

Docker is a popular platform for packaging apps as self-contained distributable artifacts. It creates images that include everything you need to run a particular software, such as its source code, third-party package dependencies, and required environment characteristics.

As Docker images can run anywhere Docker's installed, they're a viable format for distributing your CLI applications. The Docker ecosystem includes Docker Hub as an available-by-default public registry, giving you a complete tool chain for publishing, updating, and documenting your tools.

Here's how you can use Docker to package CLI apps instead of traditional OS package managers and standalone binary downloads.

Why Use Docker for CLI Apps?

Docker can make it quicker and easier for users to get your new utility installed. They get to

        docker run your-app
    

instead of having to look for platform-specific installation instructions. There's no manual extraction of

        tar
    

archives, copying into system folders, or

        PATH
    

editing involved.

Dockerized software also makes it easy for users to select different versions, perform updates, and initiate rollbacks. Each distinct release you create should get its own immutable tag that uniquely identifies its Docker image. Unlike regular package managers, users can easily run two versions of your software side-by-side by starting containers based on different image tags.

Another benefit is the ease with which users can safely try out your app without making a long-term commitment to it. People can be hesitant to add new packages to their machines lest the software fails to fully clean up after itself when removed. Docker containers have their own private filesystem; removing a container leaves no trace of its existence on the host. This could encourage more users to give your app a go.

One natural consequence of Dockerized distribution is the requirement that users already have Docker running on their machine. Nowadays many developers will be running it as a matter of course so it's a fairly safe choice to make. If you're concerned about locking out users who don't want to use Docker, you can still provide alternative options via your existing distribution channels.

Creating a Docker Image for a CLI App

Docker images for CLI apps are little different to those used for any other type of software. The objective is to provide an image that's as lightweight as possible while still bundling everything your application needs to operate.

It's usually best to start from a minimal base image that runs a streamlined operating system like Alpine. Add just the packages your software requires, such as its programming language, framework, and dependencies.

Two vital Dockerfile instructions for CLI tools are ENTRYPOINT and CMD. Together these define the foreground process that will run when containers are started from your image. Most base images will default to launching a shell when the container starts. You should change this so it's your app that runs automatically, removing the need for users to manually execute it within the container.

The ENTRYPOINT Dockerfile instruction defines the container's foreground process. Set this to your application's executable:

ENTRYPOINT ["demo-app"]

The CMD instruction works in tandem with ENTRYPOINT. It supplies default arguments for the command that's set in the ENTRYPOINT. Arguments that the user supplies when starting the container with docker run will override the CMD set in the Dockerfile.

A good use for CMD is when you want to show some basic help or version information when users omit a specific command:

ENTRYPOINT ["demo-app"]

CMD ["--version"]

Here are a few examples showing how these two instructions result in different commands being run when containers are created:

# Starting a new container from the "demo-app-image:latest" image

# Runs "demo-app --version"

docker run demo-app-image:latest

# Runs "demo-app demo --foo bar"

docker run demo-app-image:latest demo --foo bar

Neither of the examples require the user to type the demo-app executable name. It's automatically used as the foreground process because it's the configured ENTRYPOINT. The command receives the arguments the user gave to docker run after the image name. When no arguments are supplied, the default --version is used.

These two instructions are the fundamental building blocks of Docker images housing CLI tools. You want your application's main executable to be the default foreground process so users don't have to invoke it themselves.

Putting It Together

Here's a Docker image that runs a simple Node.js application:

#!/usr/local/bin/node

console.log("Hello World");

FROM node:16-alpine

WORKDIR /hello-world

COPY ./ .

RUN npm install

ENTRYPOINT ["hello-world.js"]

The Alpine-based variant of the Node base image is used to reduce your image's overall size. The application's source code is copied into the image's filesystem via the COPY instruction. The project's npm dependencies are installed and the hello-world.js script is set as the image's entrypoint.

Build the image using docker build:

docker build -t demo-app-image:latest

Now you can run the image to see Hello World emitted to your terminal:

docker run demo-app-image:latest

At this point you're ready to push your image to Docker Hub or another registry where it can be downloaded by users. Anyone with access to the image will be able to start your software using the Docker CLI alone.

Managing Persistent Data

Dockerizing a CLI application does come with some challenges. The most prominent of these is how to handle data persistence. Data created within a container is lost when that container stops unless it's saved to an outside Docker volume.

You should write data to clearly defined paths that users can mount volumes to. It's good practice to group all your persistent data under a single directory, such as /data. Avoid using too many locations that require multiple volumes to be mounted. Your getting started guide should document the volumes your application needs so users are able to set up persistence when they create their container.

# Run demo-app with a data volume mounted to /data

docker run -v demo-app-data:/data demo-app-image:latest

Other Possible Challenges

The mounting issue reappears when your command needs to interact with files on the host's filesystem. Here's a simple example of a file upload tool:

docker run file-uploader cp example.txt demo-server:/example.txt

This ends up looking for example.txt within the container. In this situation, users will need to bind mount their working directory so its content is available to the container:

docker run -v $PWD:/file-uploader file-uploader cp example.txt demo-server:/example.txt

It's also important to think about how users will supply config values to your application. If you normally read from a config file, bear in mind users will need to mount one into each container they create. Offering alternative options such as command-line flags and environment variables can streamline the experience for simple use cases:

# Setting the LOGGING_DRIVER environment variable in the container

docker run -e LOGGING_DRIVER=json demo-app-image:latest

One other challenge concerns interactive applications that require user input. Users need to pass the -it flag to docker run to enable interactive mode and allocate a pseudo-TTY:

docker run -it demo-app-image:latest

Users must remember to set these flags when necessary or your program won't be able to collect any input. You should document commands that need a TTY so users aren't caught out by unexpected errors.

These sticking points mean Dockerized applications can become unwieldy if they're not specifically designed with containerization in mind. Users get the best experience when your commands are pure, requiring no filesystem interactions and minimal configuration. When this is possible, a simple docker run image-name fulfills the objective of no-friction installation and usage. You can still containerize more complex software but you're increasingly reliant on users having a good working knowledge of the Docker CLI and its concepts.

Summary

Docker's not just for cloud deployments and background services. It's also increasingly popular as a distribution mechanism for regular console applications. You can readily publish, consume, run, and maintain software using the single docker CLI that many software practitioners already use day-to-day.

Offering a ready-to-use Docker image for your application gives users more choice. Newcomers can get started with a single command that sets up a preconfigured environment with all dependencies catered for. There's no risk of polluting their Docker host's filesystem or environment, preventing conflicts with other packages and guaranteeing the ability to revert to a clean slate if desired.

Building a Docker image is usually no more involved than the routines you're already using to submit builds to different OS package managers. The most important considerations are to keep your image as small as possible and ensure the entrypoint and command are appropriate for your application. This will give users the best possible experience when using your Dockerized software.