How to Use Docker to Package CLI Applications
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.