An R analysis that runs cleanly on your laptop can fail on a collaborator's computer, produce different results after a system update, or break when moved to a computing cluster. The cause is often the same: the software environment is only implicit. The R version, package versions, system libraries, and command-line tools needed by the project are scattered across one machine instead of recorded as part of the analysis.
That fragility becomes more expensive over time. A project may need to be reviewed, rerun, shared with a collaborator, archived with a publication, or scaled up on high-throughput computing infrastructure. If the environment is not captured, reproducing the analysis becomes a reconstruction project.
Containers help solve this problem by packaging the analysis together with the software environment it needs. A container image can run on your laptop, on a collaborator's machine, on a cloud server, or on CHTC with the same core software stack.
containr helps researchers containerize R projects from inside R. It reads
your renv.lock file, generates a Dockerfile, builds a container image, and
pushes that image to a registry. The goal is not to make every researcher a
container expert. The goal is to make a reliable, repeatable containerization
path available from the workflow researchers already use.
If you are new to containers, containr gives you a guided path through the
core steps. If you already use Docker or Podman, it reduces boilerplate and
helps standardize container generation across projects.
Use containr when you are:
renv, containers, and reproducible execution fit
together;containr is useful on its own. You do not need to submit jobs to CHTC to
benefit from a containerized R project. A container can support collaboration,
review, preservation, training, and reproducible reruns even when all work
stays local.
containr is the second step in a three-package family for reproducible
research workflows, from local project setup to containerization and
high-throughput computing submission:
toolero organize, scaffold, split
└─ containr freeze the software environment in a container
└─ submitr send the analysis to CHTC and retrieve results
Each package is useful on its own. Together, they form a path from a new local R project to a containerized analysis that can run on high-throughput computing infrastructure.
You can adopt these packages one at a time. containr does not require
toolero, and it does not require submitr. If you used
toolero::init_project() to start your project, renv is already initialized
and renv::snapshot() will produce the lockfile that containr needs. If you
did not use toolero, run renv::snapshot() in your project root before
proceeding.
containr assumes your project already has enough structure to describe its R
package environment. Before using it, confirm that:
renv and renv.lock exists in the project root, and
that it records every package your analysis actually calls -- run
renv::snapshot() after loading them, not before, or renv.lock will
exist but be empty and generate_dockerfile() will build an image with
no R packages installed. If your containerized script calls
toolero::save_output() or toolero::resolve_input_path(), toolero
itself needs to be in renv.lock too -- it's a runtime dependency of the
analysis at that point, not just a development convenience;At UW-Madison, registry.doit.wisc.edu is the default registry used by the
CHTC-oriented workflow. containr isn't limited to it -- push_image()
works against any OCI-compliant registry (GitHub Container Registry,
Quay, or another institution's GitLab instance) by supplying a different
registry argument.
Install from CRAN:
install.packages("containr")
For the latest development features, install from GitHub:
# install.packages("pak")
# pak::pak("erwinlares/containr")
install.packages("containr", repos = c('https://erwinlares.r-universe.dev', 'https://cloud.r-project.org'))
The core workflow has four steps: generate a Dockerfile, build the image,
inspect local images, and push the image to a registry. The first three steps
happen inside R. Authentication with the container registry happens once in the
terminal before you push.
library(containr)
# 1. Generate a Dockerfile from renv.lock, copying in the input data
generate_dockerfile(
r_version = "4.4.0",
data_file = "data-raw/sample.csv",
output = ".",
comments = TRUE
)
# 2. Build the image locally
build_image(verbose = TRUE)
# 3. Inspect local images
imgs <- list_images()
# 4. Push the image to the registry
push_image(
image_id = imgs$image_id[1],
namespace = "your.netid",
project = "my-analysis",
tag = "1.0.0"
)
For a first pass, use comments = TRUE when generating the Dockerfile and
dry_run = TRUE before running commands that build or push. The annotations
and previews make the container workflow easier to inspect, teach, and debug.
Notice what step 1 leaves out: the analysis script. For work sent to a
cluster, the image carries the environment and the data, and the script
travels with each job; "Where the analysis script goes" below explains why.
If your project was scaffolded with toolero, config = "_toolero.yml"
can fill in data_file for you; see "Describing a project with
_toolero.yml".
generate_dockerfile()generate_dockerfile() reads your renv.lock, identifies the R packages used
by the project, queries their system library requirements, and writes a
Dockerfile.
The generated Dockerfile is a plain text file. You can inspect it, edit it,
delete it, and regenerate it as many times as needed while refining your setup.
This makes generate_dockerfile() a low-risk entry point into
containerization: the first step is not building an image; it is making the
container recipe visible.
When you include files via data_file, code_file, or misc_file, the
generated COPY instructions preserve your local directory structure inside
the container. For "base", "tidyverse", "rstudio", and "verse", that
means under home_dir (/home/ by default) -- a file at
data-raw/sample.csv locally ends up at /home/data-raw/sample.csv in the
container, not flattened into /home/data/, and setting
home_dir = "/workspace" moves both WORKDIR and these COPY
destinations together. For "shiny_server" and "rstudio_shiny", files
land under /srv/shiny-server/ instead, regardless of home_dir --
matching Shiny Server's own default app directory. Either way, your R
scripts can use the same relative paths inside the container that they use
on your machine. All files must be inside the current working directory
(the build context) -- files outside it will produce an error, since
Dockerfile COPY cannot reach beyond the build context.
If your project was scaffolded with toolero::init_project() using
branding = TRUE or branding = "uw-madison", pass misc_file = "assets/"
to bring the branding folder along -- but note that embed-resources: true
in toolero's own Quarto templates already reduces how often you need to:
a .qmd rendered with that option produces one self-contained .html
rather than an .html plus a separate _files/ directory, so there is
less rendered output depending on files staying alongside it inside the
container. It does not eliminate the need for assets/ itself, though --
the .qmd source still references assets/styles.css,
assets/header.html, and assets/footer.html directly, and Quarto will
still error on render if those aren't present.
Each of these three arguments accepts a single path, a character vector of paths, or a path to a directory -- a directory is copied whole, contents and all, rather than one file at a time -- and files and directories can be freely mixed in the same vector.
# Generate a Dockerfile from the current project
generate_dockerfile(r_version = "4.4.0", output = ".")
# Include a data file -- preserves directory structure in the container
generate_dockerfile(
r_version = "4.4.0",
data_file = "data-raw/penguins.csv",
output = "."
)
# Several data files and a whole assets folder, copied in one call
generate_dockerfile(
r_version = "4.4.0",
data_file = c("data-raw/penguins.csv", "data-raw/islands.csv"),
misc_file = "assets/",
output = "."
)
# Use an RStudio Server image instead of plain R
generate_dockerfile(
r_version = "4.4.0",
r_mode = "rstudio",
output = "."
)
# Serve a Shiny app -- the image runs the app itself, so its code goes in
# via code_file. Files land under /srv/shiny-server/ automatically
generate_dockerfile(
r_version = "4.4.0",
r_mode = "shiny_server",
code_file = "app.R",
output = "."
)
# RStudio Server and Shiny Server together in the same image
generate_dockerfile(
r_version = "4.4.0",
r_mode = "rstudio_shiny",
code_file = "app.R",
output = "."
)
# Add extra system libraries not caught by auto-detection
generate_dockerfile(
r_version = "4.4.0",
install_syslibs = c("libuv1-dev", "libwebp-dev"),
output = "."
)
# Install Quarto, pinned to a specific release rather than whatever is
# currently latest -- the resolved version is recorded as
# ENV QUARTO_VERSION in the generated Dockerfile either way
generate_dockerfile(
r_version = "4.4.0",
install_quarto = TRUE,
quarto_version = "1.5.57",
output = "."
)
# Guided generation with progress messages and annotated Dockerfile
generate_dockerfile(
r_version = "4.4.0",
output = ".",
verbose = TRUE,
comments = TRUE
)
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, reviewing the file with collaborators, or teaching
why each layer exists.
Every other layer in the generated image is pinned deliberately -- r_version
locks R and system libraries to a Rocker snapshot, renv.lock locks R package
versions -- and quarto_version closes what was previously the one unpinned
layer. Left at the default "latest", the actual Quarto release is resolved
once at generation time and installed from a versioned URL rather than
quarto.org/download/latest/, so a Dockerfile built today and rebuilt six
months from now installs the same Quarto version both times. An explicit
version (e.g. "1.5.57") is validated against Quarto's own releases and
errors if no matching release exists. Either way, the resolved version is
recorded in the Dockerfile as ENV QUARTO_VERSION=..., so it's recoverable
from a running container without the original Dockerfile on hand.
_toolero.yml
A project scaffolded with toolero::init_project() carries a small file at
its root, _toolero.yml, that records which folders the project has and a
few naming conventions. The family calls it the project config. It looks
like this:
schema_version: 1
folders:
- data-raw
- data
- R
- scripts
- output/figures
- output/tables
- reports
- assets
conventions:
output_dir: output
script_dir: R
split_dir: data/jobs
Rather than retyping what that file already says, point
generate_dockerfile() at it:
generate_dockerfile(
r_version = "4.4.0",
config = "_toolero.yml",
output = ".",
verbose = TRUE
)
From the config, generate_dockerfile() takes three things:
data-raw, when it is declared, becomes data_file, so the input data
is copied into the image.assets, when it is declared, becomes misc_file, so the branding
files a rendered .qmd needs are there too.RUN mkdir -p
line, so a script's first write into output/figures/ does not fail
for want of the folder.It deliberately does not take the analysis script. R/ is created empty,
and script_dir is not read: the script travels with each job instead (see
the next section), so copying it in because the project happens to have an
R/ folder would only put a second, stale copy in the image.
Three rules keep this predictable. An argument you pass yourself always
wins over the config, so config only fills in what you left out. With
verbose = TRUE, generate_dockerfile() says which arguments came from the
file, so no COPY line appears from somewhere you cannot see. And reading
the file is not a dependency on toolero: the file is plain YAML, a config
you write by hand is as valid as one init_project() wrote, and a
schema_version this containr does not know produces a warning rather
than an error. The file's full description lives in section 6 of
CONVENTIONS.md.
For work sent to a cluster, the image holds the environment and the data,
and the analysis script travels separately. submitr uploads the script
with each job, alongside that job's data subset, and the execute node runs
the uploaded copy. The practical payoff is that fixing a typo in the script
means re-uploading one small file and resubmitting, not rebuilding a
multi-gigabyte image and pushing it to the registry again. That is why none
of the examples above pass code_file, and why config never fills it in.
code_file is still there for images that have to run the code themselves:
a Shiny app served from the image (see the r_mode = "shiny_server"
example), or a self-contained image handed to a collaborator who will run
it without submitr. In those cases, pass the script explicitly:
generate_dockerfile(
r_version = "4.4.0",
data_file = "data-raw/sample.csv",
code_file = "R/analysis.R",
output = "."
)
build_image()build_image() passes your Dockerfile to Podman or Docker and builds the
image locally. The build context is the current working directory, so all file
paths in the COPY instructions are resolved relative to where you call
build_image() -- typically the project root.
The platform argument defaults to "linux/amd64", which is the architecture
used by most HPC and HTC clusters. On Apple Silicon Macs, this means the build
targets a different architecture than the host. When Docker is the resolved
tool, build_image() automatically switches to docker buildx build with
--load for cross-platform builds. For Podman, --platform is passed
directly. Set platform = NULL to build for the host architecture instead.
If the target platform differs from the host, a warning is emitted about
potential QEMU emulation issues. Docker Desktop handles cross-platform builds
more reliably than Podman's QEMU layer. If builds fail with segfaults under
Podman, try tool_preference = "docker" or build on a native x86_64 machine.
That last option -- a native x86_64 machine -- doesn't have to be your own.
containr ships a GitHub Actions template (inst/templates/build-and-push.yaml)
that builds and pushes your image on a GitHub-hosted runner, which is
natively x86_64. This sidesteps QEMU entirely rather than working around
it, since there's no architecture mismatch to emulate when the runner and
the target platform already match. See "Building in CI" below.
The first build can take time because the container engine must download the
base image and install the R package environment from scratch. Later builds are
usually faster because Podman and Docker reuse cached layers when the earlier
parts of the Dockerfile have not changed.
When you supply a tag, build_image() returns that same string invisibly, so it can be captured or piped straight into push_image(image_id = ) -- see the push_image() section below for the piped example. Without a tag, there's nothing reliable to hand back yet; list_images() remains the way to find the built image's ID, exactly as in "A first workflow" above.
# Build for linux/amd64 (default) -- suitable for CHTC and most clusters
build_image(verbose = TRUE)
# Build and tag for the DoIT GitLab Container Registry
build_image(
tag = "registry.doit.wisc.edu/your.netid/my-analysis:1.0.0"
)
# Build for the host architecture (e.g. local use on Apple Silicon)
build_image(platform = NULL)
# Build for ARM64 explicitly
build_image(platform = "linux/arm64")
# Preview the build command without running it
build_image(dry_run = TRUE)
Use dry_run = TRUE when you want to see the command before running it. This
is especially helpful in examples, tutorials, and package documentation because
it shows what would happen without requiring a live container engine.
list_images()list_images() returns a data frame of images in the local image store. It is
the R equivalent of checking which images are available with podman image ls
or docker image ls.
imgs <- list_images()
#> 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
Untagged images -- those built without a name -- appear with <none> in the
repository and tag columns. The image_id column contains the hash you
pass to push_image().
push_image()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 instructions if not.
For the default registry.doit.wisc.edu registry, the UW-Madison
authentication guide, including how to create a Personal Access Token with
the right scopes, is here:
https://git.doit.wisc.edu/ERWIN.LARES/container-registry
For other registries -- ghcr.io, quay.io, or another institution's
GitLab instance -- consult that registry's own documentation for the
equivalent setup, and supply it via the registry argument.
# Push to UW-Madison's DoIT GitLab Container Registry
push_image(
image_id = imgs$image_id[1],
namespace = "your.netid",
project = "my-analysis",
tag = "1.0.0"
)
# Push to GitHub Container Registry instead of the default
push_image(
image_id = imgs$image_id[1],
namespace = "your-github-username",
project = "my-analysis",
registry = "ghcr.io",
tag = "1.0.0"
)
# Preview the tag and push commands without running them
push_image(
image_id = imgs$image_id[1],
namespace = "your.netid",
project = "my-analysis",
tag = "1.0.0",
dry_run = TRUE
)
Use explicit version tags such as "1.0.0" rather than "latest". The
"latest" tag is overwritten on every push, which makes it harder to
reconstruct which image was used for a specific result.
push_image() returns the full registry destination URI it just tagged and pushed, invisibly -- dry_run returns the same value a real push would, so the two are interchangeable as far as what comes back. That makes it possible to chain a build directly into a push, with nothing captured in an intermediate variable and nothing retyped between the two calls:
build_image(tag = "my-analysis:dev") |>
push_image(namespace = "your.netid", project = "my-analysis", tag = "1.0.0")
The returned URI can also be captured for later use -- passed directly to submitr::htc_gen_submit(container_image = ), for example, without reassembling it by hand from registry/namespace/project/tag.
Everything above runs locally, but you don't have to build and push from
your own machine. containr ships a ready-to-use GitHub Actions workflow
at inst/templates/build-and-push.yaml that calls the same
build_image()/push_image() functions from CI instead -- not a
hand-written shell equivalent, the same R functions you'd call yourself,
so the image CI produces is the same image you'd get running this
locally.
This is especially useful if you develop on Apple Silicon and target an
x86_64 cluster (the default for most HPC/HTC systems, including CHTC).
GitHub's hosted runners are natively x86_64, so building there involves
no cross-platform emulation at all -- a direct fix for the QEMU issue
described above, not a workaround for it.
To use it:
Copy inst/templates/build-and-push.yaml into your own project's
.github/workflows/ directory. You can find the installed path with:
system.file("templates", "build-and-push.yaml", package = "containr")
Add two repository secrets in your project's GitHub settings
(Settings > Secrets and variables > Actions): REGISTRY_USERNAME
and REGISTRY_PASSWORD, matching whichever registry you push to.
Edit the four values marked # CUSTOMIZE in the file -- your registry,
namespace, project name, and (if it's not at the repository root) your
Dockerfile's path.
The workflow assumes a Dockerfile is already generated and committed to
your repository -- it does not call generate_dockerfile() for you. If
your dependencies change, regenerate the Dockerfile locally and commit
the result, the same way you would for any other generated file.
Once your image is in the registry, it can be referenced by any workflow that knows how to run container images. For CHTC and other HTCondor-based systems, that image URI becomes part of the submit file:
container_image = docker://registry.doit.wisc.edu/your.netid/my-analysis:1.0.0
The submitr package handles the next step in the CHTC-oriented workflow:
generating the HTCondor submit file, uploading files to the submit node,
submitting the job, monitoring progress, and retrieving results.
You can stop at containr if your goal is a portable, reviewable, shareable R
environment. You can continue to submitr when your goal is to run that
environment on CHTC.
renv records the R package versions used by your project -- the right
starting point for reproducibility. A container goes one level lower, capturing
the operating-system-level environment needed to install and run those packages:
system libraries, command-line tools, and the base image that supplies the
runtime. containr connects these layers by using renv.lock as the source
for the generated Dockerfile.
In other words, renv and containers are not alternatives. They are
complementary layers of the same reproducibility stack. The vignette
From renv to containers: why recording your R packages may not be
enough
covers this relationship in more depth, with concrete scenarios where renv
alone is not sufficient.
The package vignettes cover the full containerization workflow and the
conceptual relationship between renv and containers:
From renv to containers: why recording your R packages may not be
enough
-- the case for containers as a complement to renv, with concrete scenarios
where renv alone is not sufficient.
A first containerization workflow with
containr
-- a step-by-step walkthrough of generate_dockerfile(), build_image(),
list_images(), and push_image(), with annotated output at each step.
containr is part of a family of packages for reproducible research workflows:
The folder names, path conventions, and shared vocabulary used consistently
across all three packages are collected in one place,
CONVENTIONS.md,
maintained in the toolero repository, since that is where those conventions
are authored.
citation("containr")
Apache License (>= 2) (c) Erwin Lares