ch-image - Man Page

Build and manage images; completely unprivileged


$ ch-image [...] build [-t TAG] [-f DOCKERFILE] [...] CONTEXT
$ ch-image [...] delete IMAGE_REF
$ ch-image [...] import PATH IMAGE_REF
$ ch-image [...] list [IMAGE_REF]
$ ch-image [...] pull [...] IMAGE_REF [IMAGE_DIR]
$ ch-image [...] push [--image DIR] IMAGE_REF [DEST_REF]
$ ch-image [...] reset
$ ch-image [...] storage-path
$ ch-image { --help | --version | --dependencies }


ch-image is a tool for building and manipulating container images, but not running them (for that you want ch-run). It is completely unprivileged, with no setuid/setgid/setcap helpers. The action to take is specified by a sub-command.

Options that print brief information and then exit:

-h,  --help

Print help and exit successfully. If specified before the sub-command, print general help and list of sub-commands; if after the sub-command, print help specific to that sub-command.


Report dependency problems on standard output, if any, and exit. If all is well, there is no output and the exit is successful; in case of problems, the exit is unsuccessful.


Print version number and exit successfully.

Common options placed before the sub-command:

-a,  --arch ARCH

Use ARCH for architecture-aware registry operations, currently pull and pulls done within build. ARCH can be: (1) yolo, to bypass architecture-aware code and use the registry’s default architecture; (2) host, to use the host’s architecture, obtained with the equivalent of uname -m (default if --arch not specified); or (3) an architecture name. If the specified architecture is not available, the error message will list which ones are.


  1. ch-image is limited to one image per image reference in builder storage at a time, regardless of architecture. For example, if you say ch-image pull --arch=foo baz and then ch-image pull --arch=bar baz, builder storage will contain one image called “baz”, with architecture “bar”.
  2. Images’ default architecture is usually amd64, so this is usually what you get with --arch=yolo. Similarly, if a registry image is architecture-unaware, it will still be pulled with --arch=amd64 and --arch=host on x86-64 hosts (other host architectures must specify --arch=yolo to pull architecture-unaware images).
  3. uname -m and image registries often use different names for the same architecture. For example, what uname -m reports as “x86_64” is known to registries as “amd64”. --arch=host should translate if needed, but it’s useful to know this is happening. Directly specified architecture names are passed to the registry without translation.
  4. Registries treat architecture as a pair of items, architecture and sometimes variant (e.g., “arm” and “v7”). Charliecloud treats architecture as a simple string and converts to/from the registry view transparently.

Download everything needed, ignoring the cache.


Re-prompt the user every time a registry password is needed.

-s,  --storage DIR

Set the storage directory (see below for important details).


Don’t verify TLS certificates of the repository. (Do not use this option unless you understand the risks.)

-v,  --verbose

Print extra chatter; can be repeated.


If the remote repository needs authentication, Charliecloud will prompt you for a username and password. Note that some repositories call the secret something other than “password”; e.g., GitLab calls it a “personal access token (PAT)”.

These values are remembered for the life of the process and silently re-offered to the registry if needed. One case when this happens is on push to a private registry: many registries will first offer a read-only token when ch-image checks if something exists, then re-authenticate when upgrading the token to read-write for upload. If your site uses one-time passwords such as provided by a security device, you can specify --password-many to provide a new secret each time.

These values are not saved persistently, e.g. in a file. Note that we do use normal Python variables for this information, without pinning them into physical RAM with mlock(2) or any other special treatment, so we cannot guarantee they will never reach non-volatile storage.

There is no separate login subcommand like Docker. For non-interactive authentication, you can use environment variables CH_IMAGE_USERNAME and CH_IMAGE_PASSWORD. Only do this if you fully understand the implications for your specific use case, because it is difficult to securely store secrets in environment variables.

Storage Directory

ch-image maintains state using normal files and directories located in its storage directory; contents include temporary images used for building and various caches.

In descending order of priority, this directory is located at:

-s, --storage DIR

Command line option.


Environment variable.


Default. (Previously, the default was /var/tmp/$USER/ch-image. If a valid storage directory is found at the old default path, ch-image tries to move it to the new default path.)

Unlike many container implementations, there is no notion of storage drivers, graph drivers, etc., to select and/or configure.

The storage directory can reside on any filesystem. However, it contains lots of small files and metadata traffic can be intense. For example, the Charliecloud test suite uses approximately 400,000 files and directories in the storage directory as of this writing. Place it on a filesystem appropriate for this; tmpfs’es such as /var/tmp are a good choice if you have enough RAM (/tmp is not recommended because ch-run bind-mounts it into containers by default).

While you can currently poke around in the storage directory and find unpacked images runnable with ch-run, this is not a supported use case. The supported workflow uses ch-convert to obtain a packed image; see the tutorial for details.

The storage directory format changes on no particular schedule. Often ch-image is able to upgrade the directory; however, downgrading is not supported and sometimes upgrade is not possible. In these cases, ch-image will refuse to run until you delete and re-initialize the directory with ch-image reset.


Network filesystems, especially Lustre, are typically bad choices for the storage directory. This is a site-specific question and your local support will likely have strong opinions.


Build an image from a Dockerfile and put it in the storage directory.


$ ch-image [...] build [-t TAG] [-f DOCKERFILE] [...] CONTEXT


Uses ch-run -w -u0 -g0 --no-home --no-passwd to execute RUN instructions. Note that FROM implicitly pulls the base image if needed, so you may want to read about the pull subcommand below as well.

Required argument:


Path to context directory. This is the root of Copy instructions in the Dockerfile. If a single hyphen (-) is specified: (a) read the Dockerfile from standard input, (b) specifying --file is an error, and (c) there is no context, so Copy will fail. (See --file for how to provide the Dockerfile on standard input while also having a context.)


-b,  --bind SRC[:DST]

For RUN instructions only, bind-mount SRC at guest DST. The default destination if not specified is to use the same path as the host; i.e., the default is equivalent to --bind=SRC:SRC. If DST does not exist, try to create it as an empty directory, though images do have ten directories /mnt/[0-9] already available as mount points. Can be repeated.

Note: See documentation for ch-run --bind for important caveats and gotchas.

Note: Other instructions that modify the image filesystem, e.g. Copy, can only access host files from the context directory, regardless of this option.

--build-arg KEY[=VALUE]

Set build-time variable KEY defined by ARG instruction to VALUE. If VALUE not specified, use the value of environment variable KEY.

-f,  --file DOCKERFILE

Use DOCKERFILE instead of CONTEXT/Dockerfile. If a single hyphen (-) is specified, read the Dockerfile from standard input; like docker build, the context directory is still available in this case.


Inject the unprivileged build workarounds; see discussion later in this section for details on what this does and when you might need it. If a build fails and ch-image thinks --force would help, it will suggest it.

-n,  --dry-run

Don’t actually execute any Dockerfile instructions.


Don’t try to detect if the workarounds in --force would help.


Stop after parsing the Dockerfile.

-t,  --tag TAG

Name of image to create. If not specified, infer the name:

  1. If Dockerfile named Dockerfile with an extension: use the extension with invalid characters stripped, e.g.
  2. If Dockerfile has extension dockerfile: use the basename with the same transformation, e.g. baz.@QUX.dockerfile -> baz.qux.
  3. If context directory is not /: use its name, i.e. the last component of the absolute path to the context directory, with the same transformation,
  4. Otherwise (context directory is /): use root.

If no colon present in the name, append :latest.

Privilege model

ch-image is a fully unprivileged image builder. It does not use any setuid or setcap helper programs, and it does not use configuration files /etc/subuid or /etc/subgid. This contrasts with the “rootless” or “fakeroot” modes of some competing builders, which do require privileged supporting code or utilities.

This approach does yield some quirks. We provide built-in workarounds that should mostly work (i.e., --force), but it can be helpful to understand what is going on.

ch-image executes all instructions as the normal user who invokes it. For RUN, this is accomplished with ch-run -w --uid=0 --gid=0 (and some other arguments), i.e., your host EUID and EGID both mapped to zero inside the container, and only one UID (zero) and GID (zero) are available inside the container. Under this arrangement, processes running in the container for each RUN appear to be running as root, but many privileged system calls will fail without the workarounds described below. This affects any fully unprivileged container build, not just Charliecloud.

The most common time to see this is installing packages. For example, here is RPM failing to chown(2) a file, which makes the package update fail:

  Updating   : 1:dbus-1.10.24-13.el7_6.x86_64                            2/4
Error unpacking rpm package 1:dbus-1.10.24-13.el7_6.x86_64
error: unpacking of archive failed on file /usr/libexec/dbus-1/dbus-daemon-launch-helper;5cffd726: cpio: chown
  Cleanup    : 1:dbus-libs-1.10.24-12.el7.x86_64                         3/4
error: dbus-1:1.10.24-13.el7_6.x86_64: install failed

This one is (ironically) apt-get failing to drop privileges:

E: setgroups 65534 failed - setgroups (1: Operation not permitted)
E: setegid 65534 failed - setegid (22: Invalid argument)
E: seteuid 100 failed - seteuid (22: Invalid argument)
E: setgroups 0 failed - setgroups (1: Operation not permitted)

By default, nothing is done to avoid these problems, though ch-image does try to detect if the workarounds could help. --force activates the workarounds: ch-image injects extra commands to intercept these system calls and fake a successful result, using fakeroot(1). There are three basic steps:

  1. After FROM, analyze the image to see what distribution it contains, which determines the specific workarounds.
  2. Before the user command in the first RUN instruction where the injection seems needed, install fakeroot(1) in the image, if one is not already installed, as well as any other necessary initialization commands. For example, we turn off the apt sandbox (for Debian Buster) and configure EPEL but leave it disabled (for CentOS/RHEL).
  3. Prepend fakeroot to RUN instructions that seem to need it, e.g. ones that contain apt, apt-get, dpkg for Debian derivatives and dnf, rpm, or yum for RPM-based distributions.

The details are specific to each distribution. ch-image analyzes image content (e.g., grepping /etc/debian_version) to select a configuration; see lib/ for details. ch-image prints exactly what it is doing.

Compatibility with other Dockerfile interpreters

ch-image is an independent implementation and shares no code with other Dockerfile interpreters. It uses a formal Dockerfile parsing grammar developed from the Dockerfile reference documentation and miscellaneous other sources, which you can examine in the source code.

We believe this independence is valuable for several reasons. First, it helps the community examine Dockerfile syntax and semantics critically, think rigorously about what is really needed, and build a more robust standard. Second, it yields disjoint sets of bugs (note that Podman, Buildah, and Docker all share the same Dockerfile parser). Third, because it is a much smaller code base, it illustrates how Dockerfiles work more clearly. Finally, it allows straightforward extensions if needed to support scientific computing.

ch-image tries hard to be compatible with Docker and other interpreters, though as an independent implementation, it is not bug-compatible.

The following subsections describe differences from the Dockerfile reference that we expect to be approximately permanent. For not-yet-implemented features and bugs in this area, see related issues on GitHub.

None of these are set in stone. We are very interested in feedback on our assessments and open questions. This helps us prioritize new features and revise our thinking about what is needed for HPC containers.

Context directory

The context directory is bind-mounted into the build, rather than copied like Docker. Thus, the size of the context is immaterial, and the build reads directly from storage like any other local process would. However, you still can’t access anything outside the context directory.

Variable substitution

Variable substitution happens for all instructions, not just the ones listed in the Dockerfile reference.

ARG and ENV cause cache misses upon definition, in contrast with Docker where these variables miss upon use, except for certain cache-excluded variables that never cause misses, listed below.

ch-image passes the following proxy environment variables in to the build. Changes to these variables do not cause a cache miss. They do not require an ARG instruction, as documented in the Dockerfile reference. Unlike Docker, they are available if the same-named environment variable is defined; --build-arg is not required.


In addition to those listed in the Dockerfile reference, these environment variables are passed through in the same way:


Finally, these variables are also pre-defined but are unrelated to the host environment:


Note that ARG and ENV have different syntax despite very similar semantics.


Especially for people used to UNIX cp(1), the semantics of the Dockerfile COPY instruction can be confusing.

Most notably, when a source of the copy is a directory, the contents of that directory, not the directory itself, are copied. This is documented, but it’s a real gotcha because that’s not what cp(1) does, and it means that many things you can do in one cp(1) command require multiple COPY instructions.

Also, the reference documentation is incomplete. In our experience, Docker also behaves as follows; ch-image does the same in an attempt to be bug-compatible.

  1. You can use absolute paths in the source; the root is the context directory.
  2. Destination directories are created if they don’t exist in the following situations:

    1. If the destination path ends in slash. (Documented.)
    2. If the number of sources is greater than 1, either by wildcard or explicitly, regardless of whether the destination ends in slash. (Not documented.)
    3. If there is a single source and it is a directory. (Not documented.)
  3. Symbolic links behave differently depending on how deep in the copied tree they are. (Not documented.)

    1. Symlinks at the top level — i.e., named as the destination or the source, either explicitly or by wildcards — are dereferenced. They are followed, and whatever they point to is used as the destination or source, respectively.
    2. Symlinks at deeper levels are not dereferenced, i.e., the symlink itself is copied.
  4. If a directory appears at the same path in source and destination, and is at the 2nd level or deeper, the source directory’s metadata (e.g., permissions) are copied to the destination directory. (Not documented.)
  5. If an object appears in both the source and destination, and is at the 2nd level or deeper, and is of different types in the source and destination, then the source object will overwrite the destination object. (Not documented.) For example, if /tmp/foo/bar is a regular file, and /tmp is the context directory, then the following Dockerfile snippet will result in a file in the container at /foo/bar (copied from /tmp/foo/bar); the directory and all its contents will be lost.

    RUN mkdir -p /foo/bar && touch /foo/bar/baz
    COPY foo /foo

We expect the following differences to be permanent:

  • Wildcards use Python glob semantics, not the Go semantics.
  • COPY --chown is ignored, because it doesn’t make sense in an unprivileged build.

Features we do not plan to support

  • Parser directives are not supported. We have not identified a need for any of them.
  • EXPOSE: Charliecloud does not use the network namespace, so containerized processes can simply listen on a host port like other unprivileged processes.
  • HEALTHCHECK: This instruction’s main use case is monitoring server processes rather than applications. Also, implementing it requires a container supervisor daemon, which we have no plans to add.
  • MAINTAINER is deprecated.
  • STOPSIGNAL requires a container supervisor daemon process, which we have no plans to add.
  • USER does not make sense for unprivileged builds.
  • VOLUME: This instruction is not currently supported. Charliecloud has good support for bind mounts; we anticipate that it will continue to focus on that and will not introduce the volume management features that Docker has.


Build image bar using ./foo/bar/Dockerfile and context directory ./foo/bar:

$ ch-image build -t bar -f ./foo/bar/Dockerfile ./foo/bar
grown in 4 instructions: bar

Same, but infer the image name and Dockerfile from the context directory path:

$ ch-image build ./foo/bar
grown in 4 instructions: bar

Build using humongous vendor compilers you want to bind-mount instead of installing into the image:

$ ch-image build --bind /opt/bigvendor:/opt .
$ cat Dockerfile
FROM centos:7

RUN /opt/bin/cc hello.c
#COPY /opt/lib/*.so /usr/local/lib   # fail: COPY doesn't bind mount
RUN cp /opt/lib/*.so /usr/local/lib  # possible workaround
RUN ldconfig


$ ch-image [...] delete IMAGE_REF

Delete the image described by the image reference IMAGE_REF from the storage directory.


Print information about images. If no argument given, list the images in builder storage.


$ ch-image [...] list [IMAGE_REF]


Optional argument:


Print details of what’s known about IMAGE_REF, both locally and in the remote registry, if any.


List images in builder storage:

$ ch-image list
alpine:3.9 (amd64)
alpine:latest (amd64)
debian:buster (amd64)

Print details about Debian Buster image:

$ ch-image list debian:buster
details of image:    debian:buster
in local storage:    no
full remote ref:
available remotely:  yes
remote arch-aware:   yes
host architecture:   amd64
archs available:     386 amd64 arm/v5 arm/v7 arm64/v8 mips64le ppc64le s390x


$ ch-image [...] import PATH IMAGE_REF

Copy the image at PATH into builder storage with name IMAGE_REF. PATH can be:

If the imported image contains Charliecloud metadata, that will be imported unchanged, i.e., images exported from ch-image builder storage will be functionally identical when re-imported.


Pull the image described by the image reference IMAGE_REF from a repository to the local filesystem.


$ ch-image [...] pull [...] IMAGE_REF [IMAGE_DIR]

See the FAQ for the gory details on specifying image references.




If specified, place the unpacked image at this path; it is then ready for use by ch-run or other tools. The storage directory will not contain a copy of the image, i.e., it is only unpacked once.


--last-layer N

Unpack only N layers, leaving an incomplete image. This option is intended for debugging.


Parse IMAGE_REF, print a parse report, and exit successfully without talking to the internet or touching the storage directory.

This script does a fair amount of validation and fixing of the layer tarballs before flattening in order to support unprivileged use despite image problems we frequently see in the wild. For example, device files are ignored, and file and directory permissions are increased to a minimum of rwx------ and rw------- respectively. Note, however, that symlinks pointing outside the image are permitted, because they are not resolved until runtime within a container.

The following metadata in the pulled image is retained; all other metadata is currently ignored. (If you have a need for additional metadata, please let us know!)

  • Current working directory set with WORKDIR is effective in downstream Dockerfiles.
  • Environment variables set with ENV are effective in downstream Dockerfiles and also written to /ch/environment for use in ch-run --set-env.
  • Mount point directories specified with VOLUME are created in the image if they don’t exist, but no other action is taken.

Note that some images (e.g., those with a “version 1 manifest”) do not contain metadata. A warning is printed in this case.


Download the Debian Buster image matching the host’s architecture and place it in the storage directory:

$ uname -m
pulling image:    debian:buster
requesting arch:  arm64/v8
manifest list: downloading
manifest: downloading
config: downloading
layer 1/1: c54d940: downloading
flattening image
layer 1/1: c54d940: listing
validating tarball members
resolving whiteouts
layer 1/1: c54d940: extracting
image arch: arm64

Same, specifying the architecture explicitly:

$ ch-image --arch=arm/v7 pull debian:buster
pulling image:    debian:buster
requesting arch:  arm/v7
manifest list: downloading
manifest: downloading
config: downloading
layer 1/1: 8947560: downloading
flattening image
layer 1/1: 8947560: listing
validating tarball members
resolving whiteouts
layer 1/1: 8947560: extracting
image arch: arm (may not match host arm64/v8)

Download the same image and place it in /tmp/buster:

$ ch-image pull debian:buster /tmp/buster
$ ls /tmp/buster
bin   dev  home  lib64  mnt  proc  run   srv  tmp  var
boot  etc  lib   media  opt  root  sbin  sys  usr


Push the image described by the image reference IMAGE_REF from the local filesystem to a repository.


$ ch-image [...] push [--image DIR] IMAGE_REF [DEST_REF]

See the FAQ for the gory details on specifying image references.




If specified, use this as the destination image reference, rather than IMAGE_REF. This lets you push to a repository without permanently adding a tag to the image.


--image DIR

Use the unpacked image located at DIR rather than an image in the storage directory named IMAGE_REF.

Because Charliecloud is fully unprivileged, the owner and group of files in its images are not meaningful in the broader ecosystem. Thus, when pushed, everything in the image is flattened to user:group root:root. Also, setuid/setgid bits are removed, to avoid surprises if the image is pulled by a privileged container implementation.


Push a local image to the registry at path /foo/bar with tag latest. Note that in this form, the local image must be named to match that remote reference.

$ ch-image push
pushing image:
layer 1/1: gathering
layer 1/1: preparing
preparing metadata
starting upload
layer 1/1: a1664c4: checking if already in repository
layer 1/1: a1664c4: not present, uploading
config: 89315a2: checking if already in repository
config: 89315a2: not present, uploading
manifest: uploading
cleaning up

Same, except use local image alpine:3.9. In this form, the local image name does not have to match the destination reference.

$ ch-image push alpine:3.9
pushing image:   alpine:3.9
layer 1/1: gathering
layer 1/1: preparing
preparing metadata
starting upload
layer 1/1: a1664c4: checking if already in repository
layer 1/1: a1664c4: not present, uploading
config: 89315a2: checking if already in repository
config: 89315a2: not present, uploading
manifest: uploading
cleaning up

Same, except use unpacked image located at /var/tmp/image rather than an image in ch-image storage. (Also, the sole layer is already present in the remote registry, so we don’t upload it again.)

$ ch-image push --image /var/tmp/image
pushing image:
image path:      /var/tmp/image
layer 1/1: gathering
layer 1/1: preparing
preparing metadata
starting upload
layer 1/1: 892e38d: checking if already in repository
layer 1/1: 892e38d: already present
config: 546f447: checking if already in repository
config: 546f447: not present, uploading
manifest: uploading
cleaning up


$ ch-image [...] reset

Delete all images and cache from ch-image builder storage.


$ ch-image [...] storage-path

Print the storage directory path and exit.

Environment Variables


Username and password for registry authentication. See important caveats in section “Authentication” above.


If set, append log chatter to this file, rather than standard error. This is useful for debugging situations where standard error is consumed or lost.

Also sets verbose mode if not already set (equivalent to --verbose).


If set, prepend PID and timestamp to logged chatter.

Reporting Bugs

If Charliecloud was obtained from your Linux distribution, use your distribution’s bug reporting procedures.

Otherwise, report bugs to:

See Also


Full documentation at: <>

Referenced By


2022-07-20 00:00 UTC 0.26 Charliecloud