
A first containerization workflow with containr
Created 2026-04-30 | Last updated 2026-05-20
Source:vignettes/containr-workflow.Rmd
containr-workflow.Rmd
Before you start
This vignette walks through the complete containr
workflow: generating a Dockerfile, building a container
image, inspecting local images, and pushing the image to a registry. It
assumes you are comfortable with R and have some familiarity with the
idea of containers. If you are new to containers and want to understand
why they complement renv rather than replace it, start with
the companion vignette From renv to containers: why recording your R
packages may not be enough.
Before running any of the code below, confirm that:
- your project uses
renvandrenv.lockexists in the project root; - Podman or Docker is installed and running;
- you have access to a container registry if you plan to push the image.
At UW-Madison, registry.doit.wisc.edu is the default
registry for the CHTC-oriented workflow. The authentication guide,
including how to create a Personal Access Token with the right scopes,
is at https://git.doit.wisc.edu/ERWIN.LARES/container-registry.
If you are working through this vignette for the first time,
dry_run = TRUE is available on build_image()
and push_image(). It prints the command that would be run
without executing it. Use it freely until you are confident in each
step.
Step 1: Generate a Dockerfile
generate_dockerfile() reads your renv.lock,
infers the system library requirements of your R packages, and writes a
Dockerfile in the project root. It is the entry point into
the containr workflow and the step that does the most work
on your behalf.
generate_dockerfile(
r_version = "4.4.0",
output = ".",
comments = TRUE
)The r_version argument should match the R version
recorded in your renv.lock. Using a consistent R version
between your lockfile and your base image reduces the chance of package
installation failures inside the container.
The comments = TRUE argument annotates each instruction
in the generated Dockerfile with an explanation of what it
does. This is useful when you are learning containerization or reviewing
the file with collaborators. Here is what the generated
Dockerfile looks like with comments enabled:
# Base image: rocker/r-ver provides a minimal R installation on Ubuntu.
# Pinning the R version ensures the container matches your renv.lock.
FROM rocker/r-ver:4.4.0
# Suppress interactive prompts during apt-get package installation.
ENV DEBIAN_FRONTEND=noninteractive
# Install system libraries required by your R packages.
# These are inferred from the packages recorded in renv.lock.
RUN apt-get update && apt-get install -y \
curl \
git \
libcurl4-openssl-dev \
libssl-dev \
libxml2-dev \
libgit2-dev \
cmake \
make \
libfreetype6-dev \
libjpeg-dev \
libpng-dev \
libtiff-dev \
libfontconfig1-dev \
libfribidi-dev \
libharfbuzz-dev \
pandoc \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory inside the container.
WORKDIR /home
# Copy renv.lock into the container so renv can restore the R package
# environment at build time.
COPY renv.lock /home/renv.lock
# Install renv from CRAN, then restore the R package environment from
# renv.lock. This step reproduces your exact package versions inside
# the container.
RUN R -e "install.packages('renv', repos='https://packagemanager.posit.co/cran/latest')"
RUN R -e "renv::restore()"The Dockerfile is a plain text file. You can inspect it,
edit it, and regenerate it as many times as needed. If your project has
unusual system library requirements that
generate_dockerfile() did not catch, add them to the
install_syslibs argument:
generate_dockerfile(
r_version = "4.4.0",
output = ".",
install_syslibs = c("libuv1-dev", "libwebp-dev")
)If your analysis depends on data files or scripts that should be
available inside the container, pass them via data_file and
code_file. The generated COPY instructions
preserve your local directory structure under /home/ – a
file at data-raw/sample.csv locally becomes
/home/data-raw/sample.csv in the container. All files must
be inside the current working directory (the build context).
generate_dockerfile(
r_version = "4.4.0",
data_file = "data-raw/sample.csv",
code_file = "analysis.R",
output = ".",
comments = TRUE
)If your project uses RStudio Server rather than plain R, pass
r_mode = "rstudio" to use the rocker/rstudio
base image instead:
generate_dockerfile(
r_version = "4.4.0",
r_mode = "rstudio",
output = "."
)Take a few minutes to read the generated Dockerfile
before moving on. Understanding what it does makes the subsequent steps
easier to reason about and debug if something goes wrong.
Step 2: Build the image
build_image() passes your Dockerfile to
Podman or Docker and builds the image locally. The first build pulls the
base image and installs every R package in your renv.lock
from scratch, so it can take several minutes depending on the size of
your package library and your network connection.
build_image(verbose = TRUE)The platform argument defaults to
"linux/amd64", which is the architecture used by CHTC and
most HPC clusters. On Apple Silicon Macs, this means the image targets a
different architecture than the host. When Docker is the resolved tool,
build_image() automatically uses
docker buildx build with --load for
cross-platform builds. For Podman, --platform is passed
directly. If the target platform differs from the host, a warning is
emitted about potential emulation issues.
Docker Desktop handles cross-platform builds more reliably than
Podman’s QEMU emulation layer. If builds fail with segfaults under
Podman, try tool = "docker" or build on a native x86_64
machine.
# Build for the host architecture (e.g. local use on Apple Silicon)
build_image(platform = NULL, verbose = TRUE)
# Build for ARM64 explicitly
build_image(platform = "linux/arm64", verbose = TRUE)With verbose = TRUE, the build output streams to the
console so you can watch the installation progress. The output looks
something like this:
i Resolving tool: using "docker"
i Target platform: "linux/amd64"
i Building image (no tag applied)
i Build context: /home/user/my-analysis, Dockerfile: Dockerfile
STEP 1/7: FROM rocker/r-ver:4.4.0
STEP 2/7: ENV DEBIAN_FRONTEND=noninteractive
STEP 3/7: RUN apt-get update && apt-get install -y ...
...
STEP 6/7: RUN R -e "install.packages('renv', ...)"
STEP 7/7: RUN R -e "renv::restore()"
v Image built successfully.If you want to preview the build command without running it:
build_image(dry_run = TRUE)
#> docker buildx build --platform linux/amd64 --load -f Dockerfile .Subsequent builds are usually faster because Podman and Docker cache
layers. If only your renv.lock changed, the system library
installation step is reused from cache and only the R package
installation step reruns.
A common first-build failure is a missing system library. If
renv::restore() fails inside the container with a message
about a missing header file or a failed compilation, add the relevant
library to install_syslibs in
generate_dockerfile(), regenerate the
Dockerfile, and rebuild.
Step 3: Inspect local images
list_images() returns a data frame of images in the
local image store. It is the R equivalent of
podman image ls or docker image ls.
imgs <- list_images()
imgs repository tag image_id created size
1 registry.doit.wisc.edu/your.netid/my-analysis 1.0.0 974123909a36 2 hours ago 1.59 GB
2 <none> <none> 3b8f20dc1a47 3 hours ago 1.21 GB
The image_id column contains the hash you pass to
push_image(). Untagged images — those built without a name
— appear with <none> in the repository
and tag columns. These accumulate during development as you
rebuild with different settings and can be pruned periodically.
Use imgs$image_id[1] to pass the most recently built
image to the next step, or select a specific row if you have multiple
images and want to push a particular one.
Step 4: Push the image to the registry
push_image() tags a local image with a registry path and
pushes it to a container registry. Before pushing, authenticate with the
registry once in a terminal. containr checks whether you
are logged in before attempting the push and errors with clear
instructions if not.
push_image(
image_id = imgs$image_id[1],
netid = "your.netid",
project = "my-analysis",
tag = "1.0.0"
)The push output looks something like this:
ℹ Tagging image 974123909a36 as
registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
ℹ Pushing to registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
...
✔ Image pushed successfully.
ℹ Image URI: docker://registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
To preview the tag and push commands without running them:
push_image(
image_id = imgs$image_id[1],
netid = "your.netid",
project = "my-analysis",
tag = "1.0.0",
dry_run = TRUE
)
#> podman tag 974123909a36 registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
#> podman push registry.doit.wisc.edu/your.netid/my-analysis:1.0.0A note on tagging: use explicit version tags like
"1.0.0" rather than "latest". The
"latest" tag is overwritten on every push, which makes it
difficult to reconstruct which image was used for a specific result. An
explicit version tag ties the image to a specific state of the analysis
and can be referenced unambiguously in submit files, documentation, and
data management plans.
Putting it together
The complete workflow from a project with a renv.lock to
a pushed container image takes four function calls:
library(containr)
# 1. Generate the Dockerfile
generate_dockerfile(
r_version = "4.4.0",
output = ".",
comments = TRUE
)
# 2. Build the image
build_image(verbose = TRUE)
# 3. Inspect local images
imgs <- list_images()
# 4. Push to the registry
push_image(
image_id = imgs$image_id[1],
netid = "your.netid",
project = "my-analysis",
tag = "1.0.0"
)The image URI returned by push_image() is the reference
you pass to any downstream workflow that needs to run the containerized
analysis. For HTCondor submissions, it goes in the submit file:
container_image = docker://registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
The submitr package handles the next step: generating
the submit file, uploading files to the submit node, dispatching the
job, and retrieving results. You can stop here if your goal is a
portable, shareable R environment, and return to submitr
when the project is ready to run on CHTC.
Troubleshooting
The build fails with a missing system library. Add
the library to install_syslibs in
generate_dockerfile(), regenerate, and rebuild. The error
message from the failed compilation usually names the missing library or
header file directly.
renv::restore() fails inside the
container. Check whether the package requires a system library
that generate_dockerfile() did not infer automatically.
Packages with compiled C or C++ code are the most common source of this
problem.
push_image() errors with an authentication
message. Run podman login registry.doit.wisc.edu
in a terminal and authenticate before retrying. The authentication guide
is at https://git.doit.wisc.edu/ERWIN.LARES/container-registry.
The image is very large. The size is driven
primarily by the number of R packages in your renv.lock and
their system library dependencies. This is expected. A project with many
packages will produce a large image. Size can be reduced by trimming
unused packages from the lockfile before building.
The build fails with a QEMU segfault on Apple
Silicon. Building linux/amd64 images on ARM hosts
requires emulation, which can crash during R package installation.
Switch to Docker Desktop (tool = "docker"), which uses
buildx and handles cross-platform builds more reliably.
Alternatively, build on a native x86_64 machine or via GitHub
Actions.