feat: add use_dockerfile() for R package containerization - #2258
Open
jkylearmstrong wants to merge 6 commits into
Open
feat: add use_dockerfile() for R package containerization#2258jkylearmstrong wants to merge 6 commits into
jkylearmstrong wants to merge 6 commits into
Conversation
jkylearmstrong
commented
Jul 16, 2026
- Generate multi-stage Dockerfile optimized for R development
- Support optional Quarto, tidyverse, tidymodels, LaTeX, pandoc
- Auto-detect dependencies from DESCRIPTION
- Create .dockerignore and add Dockerfile to .Rbuildignore
- Non-root user for security, layer caching for fast builds
- Version bump to 3.2.2.9000
- Generate multi-stage Dockerfile optimized for R development - Support optional Quarto, tidyverse, tidymodels, LaTeX, pandoc - Auto-detect dependencies from DESCRIPTION - Create .dockerignore and add Dockerfile to .Rbuildignore - Non-root user for security, layer caching for fast builds - Version bump to 3.2.2.9000
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Bug fixes
* Preserve package name case (DBI, clubSandwich, etc.) when building install list. Previously, olower() mangled names before they reached install.packages().
* Use character(0) instead of c('') when no R packages are needed, preventing invalid install.packages(c('')) calls.
* Non-root user creation now checks for existing users (id -u ruser) rather than swallowing all errors with || echo.
## New validation
* Validate R_version is a single character string (prevents silent glue recycling).
* Validate 锟絘se_image does not include a tag (prevents invalid FROM foo:bar:4.3.3).
* Validate package names are well-formed before interpolating into Dockerfile syntax.
## Reproducibility
* Default R package repository is now Posit Public Package Manager with a date-pinned snapshot instead of the rolling CRAN mirror. New
epos parameter allows override.
## Improvements
* Multi-stage build no longer copies redundant system binary directories (/usr/bin, /usr/local/bin) from the builder stage.
* Reformat Dockerfile template from single-line string to readable multi-line format.
* Add
env/library and
env/staging to .dockerignore.
* Add ixed = TRUE to grepl() calls for quarto/rmarkdown detection.
## Documentation
* Fix R_version default in roxygen (was paste0('4.', ...), now paste0(R.version, '.', ...)).
* Fix 锟絘se_image default in roxygen (was 'r-base', now 'rocker/r-base').
* Fix use_quarto/use_pandoc docs to reflect they check Imports+Suggests, not just Suggests.
* Fix @return to match actual invisible(TRUE) return value.
* Add use_dockerfile() to README.Rmd Usage section.
* Delete review notes file after addressing all items.
#' #' @description #' Automate Docker containerization for an R package or project. #' Creates a multi-stage Dockerfile optimized for R development. #' #' @PARAM R_version Character string of R version to use (e.g., "4.4.0"). #' Defaults to current version via `paste0(R.version$major, ".", R.version$minor)`. #' @PARAM use_quarto Logical. Include Quarto (>= 1.5.1) for documentation rendering. #' Default `TRUE` if quarto is in Imports or Suggests. #' @PARAM use_tidyverse Logical. Include tidyverse collection (ggplot2, dplyr, tidyr, etc.). #' Default `FALSE`. #' @PARAM use_tidymodels Logical. Include tidymodels for modeling workflows. #' Default `FALSE`. #' @PARAM use_latex Logical. Include TinyTeX for LaTeX/PDF rendering. #' Default `FALSE`. #' @PARAM use_pandoc Logical. Include pandoc for document conversion. #' Default `TRUE` if rmarkdown is in Imports or Suggests. #' @PARAM use_git Logical. Include git in the image. Default `TRUE`. #' @PARAM additional_packages Character vector of additional system packages to install #' (e.g., `c("curl-dev", "libxml2-dev")`). Default `NULL`. #' @PARAM base_image Character. Base image URI without tag. #' Default `"rocker/r-ver"` (Ubuntu Jammy-based, includes pandoc). #' Do not include a tag; the tag is controlled by `R_version`. #' @PARAM workdir Character. Working directory inside container. Default `"/workspace"`. #' @PARAM repos Character. R package repositories for `install.packages()`. #' Default uses [Posit Public Package Manager](https://packagemanager.posit.co) #' with a date-pinned snapshot for reproducibility. #' @PARAM open Logical. Open the generated Dockerfile after creation? Default `TRUE` #' if interactive. #' #' @details #' Uses multi-stage Docker build: #' 1. **builder stage**: Installs system dependencies and R packages. #' 2. **final stage**: Runtime image with installed packages and project code. #' #' Best practices applied: #' - Layer caching for fast iterative builds #' - Separate system deps, R package deps, and source code layers #' - Non-root user for security #' - `.dockerignore` to reduce build context #' - Date-pinned package repository for reproducibility #' #' Package dependencies are extracted from DESCRIPTION imports/suggests. #' #' @return `TRUE` invisibly. Called for side effects (file creation). #' #' @Seealso #' - [Docker documentation](https://docs.docker.com/develop/dev-best-practices/) #' - [Rocker project](https://rocker-project.org/) for R base images #' - [Quarto](https://quarto.org/) for documentation #' #' @export #' @examples #' \dontrun{ #' # Basic R package with tidyverse #' use_dockerfile(use_tidyverse = TRUE) #' #' # Full stack: tidymodels, Quarto, LaTeX #' use_dockerfile( #' use_tidymodels = TRUE, #' use_quarto = TRUE, #' use_latex = TRUE #' ) #' #' # Custom R version and system packages #' use_dockerfile( #' R_version = "4.3.2", #' additional_packages = c("curl-dev", "libxml2-dev") #' ) #' } use_dockerfile <- function( R_version = NULL, use_quarto = NULL, use_tidyverse = FALSE, use_tidymodels = FALSE, use_latex = FALSE, use_pandoc = NULL, use_git = TRUE, additional_packages = NULL, base_image = "rocker/r-ver", workdir = "/workspace", repos = NULL, open = rlang::is_interactive() ) { check_is_project() if (grepl(":", base_image)) { ui_abort(c( "!" = "{.arg base_image} must not include a tag.", "i" = "Pass just the image name (e.g., {.val rocker/r-ver}) and use {.arg R_version} to control the tag." )) } # Set defaults if (is.null(R_version)) { R_version <- paste0(R.version$major, ".", R.version$minor) } if (length(R_version) != 1L) { ui_abort("{.arg R_version} must be a single character string.") } if (is.null(repos)) { repos <- paste0( "https://packagemanager.posit.co/cran/__linux__/jammy/", Sys.Date() ) } desc <- proj_desc() deps <- desc$get_deps() # Robustly get all dependencies from DESCRIPTION r_pkgs <- deps$package[deps$type %in% c("Depends", "Imports", "Suggests", "LinkingTo")] r_pkgs <- setdiff(r_pkgs, "R") # Remove R itself if (is.null(use_quarto)) { use_quarto <- "quarto" %in% tolower(r_pkgs) } if (is.null(use_pandoc)) { use_pandoc <- "rmarkdown" %in% tolower(r_pkgs) } project_name <- project_name() # Build R packages list if (use_tidyverse) r_pkgs <- c(r_pkgs, "tidyverse") if (use_tidymodels) r_pkgs <- c(r_pkgs, "tidymodels") if (use_quarto) r_pkgs <- c(r_pkgs, "quarto") # Note: if use_latex, we use system texlive rather than tinytex to avoid bloat if (use_pandoc) r_pkgs <- c(r_pkgs, "rmarkdown") r_pkgs <- unique(sort(r_pkgs)) check_docker_pkgs(r_pkgs) r_pkgs_str <- if (length(r_pkgs)) { paste0('c("', paste(r_pkgs, collapse = '", "'), '")') } else { "character(0)" } # Build system packages list sys_pkgs <- c() if (use_git) { sys_pkgs <- c(sys_pkgs, "git") } if (use_latex) { sys_pkgs <- c( sys_pkgs, "texlive-latex-base", "texlive-fonts-recommended", "texlive-latex-extra" ) } if (!is.null(additional_packages)) { sys_pkgs <- c(sys_pkgs, additional_packages) } sys_pkgs <- unique(sys_pkgs) # Use apt for Debian/Ubuntu-based rocker images if (length(sys_pkgs) > 0) { sys_pkgs_str <- paste0( "RUN apt-get update && apt-get install -y --no-install-recommends ", paste(sys_pkgs, collapse = " "), " && rm -rf /var/lib/apt/lists/*" ) } else { sys_pkgs_str <- "# No additional system packages needed" } # Build Dockerfile content dockerfile_content <- glue( " # syntax=docker/dockerfile:1.6 # Multi-stage build for R package: {project_name} # Stage 1: builder FROM {base_image}:{R_version} AS builder ENV R_LIBS=/rlib WORKDIR /build # Install system dependencies {sys_pkgs_str} # Install R packages into a dedicated library RUN mkdir -p /rlib && Rscript -e 'install.packages({r_pkgs_str}, repos = \"{repos}\", lib = \"/rlib\")' # Stage 2: final runtime FROM {base_image}:{R_version} LABEL org.opencontainers.image.title=\"{project_name}\" # Create non-root user (skip if already exists) RUN id -u ruser >/dev/null 2>&1 || useradd -m -u 1000 ruser # Install runtime system deps {sys_pkgs_str} WORKDIR {workdir} # Copy R packages from builder COPY --from=builder /rlib /rlib ENV R_LIBS=/rlib # Copy project source COPY --chown=ruser:ruser . . # Ensure working directory is owned by non-root user RUN chown -R ruser:ruser {workdir} # Switch to non-root user USER ruser # Default: R interactive CMD [\"R\", \"--no-save\"] ", .trim = FALSE ) # Ensure .dockerignore exists dockerignore_path <- proj_path(".dockerignore") if (!fs::file_exists(dockerignore_path)) { use_build_ignore(".dockerignore", escape = FALSE) writeLines( c( ".git", ".gitignore", ".Rbuildignore", ".Rproj.user", ".vscode", "README.md", "NEWS.md", "*.log", "*.swp", "*.swo", "*~", ".DS_Store", "revdep/", "pkgdown/", "renv/library", "renv/staging" ), dockerignore_path ) } # Ensure Dockerfile is in .Rbuildignore use_build_ignore("Dockerfile") # Write Dockerfile new <- write_over( proj_path("Dockerfile"), dockerfile_content, quiet = FALSE ) # Cleanly construct UI features list features <- c( use_quarto && "Quarto", use_tidyverse && "Tidyverse", use_tidymodels && "Tidymodels", use_latex && "LaTeX" ) features <- features[features] bullets <- c( "v" = "Dockerfile created at {.path {pth('Dockerfile')}}", "i" = "To build: {.code docker build -t {tolower(project_name)}:{R_version} .}", "i" = "To run: {.code docker run -it {tolower(project_name)}:{R_version}}", "i" = "R version: {R_version}", if (length(features)) "i" = "Features: {paste(features, collapse = ', ')}" ) ui_bullets(bullets) if (open) { edit_file(proj_path("Dockerfile")) } invisible(TRUE) } #' Validate package names for Dockerfile generation #' #' @Keywords internal #' @nord check_docker_pkgs <- function(pkgs) { if (length(pkgs) == 0) { return(invisible()) } # Per WRE: start with letter, >= 2 chars, <= 63 chars, letters/digits/dots, no trailing dot bad <- pkgs[!grepl("^[A-Za-z][A-Za-z0-9.]{1,62}$", pkgs) | grepl("\\.$", pkgs)] if (length(bad) > 0) { ui_abort(c( "x" = "Invalid package name{?s}: {.val {bad}}.", "i" = "Package names must start with a letter, be 2-63 characters, contain only ASCII letters, digits, and '.', and not end with a '.'." )) } invisible() }
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.