rguides

R Docker: reproducible R environments with Rocker

Why put R in a container

If you’ve ever had a script break because someone upgraded their R version, or watched install.packages("sf") fail because libgdal is missing on the build server, you already understand the case for putting R in Docker. An R Docker image bundles the R interpreter, the system libraries your packages need, your R package versions, and your code into a single artifact that runs the same way on your laptop, your colleague’s Mac, and a CI runner.

The other payoffs: reviewers can run your analysis without installing anything, you can ship a Shiny app or Plumber API without spelling out system prerequisites, and “works on my machine” stops being a category of bug.

The most common way to get an R Docker image is the Rocker project, which is a versioned, pinned, community-maintained stack of base images.

The Rocker image stack

Rocker gives you a layered set of images, each adding more on top of a base R build. Pick the smallest one that has what you need; you can always install more packages yourself.

ImageAdds on top
rocker/r-verR built from source, RSPM as the default CRAN mirror
rocker/rstudioRStudio Server
rocker/tidyversetidyverse + devtools
rocker/verseLaTeX, pandoc, and publishing tooling
rocker/geospatialGDAL, GEOS, PROJ, sf, terra, stars
rocker/shinyShiny Server (port 3838)
rocker/shiny-versetidyverse on top of rocker/shiny
rocker/cudaCUDA toolkit
rocker/mltidyverse + CUDA
rocker/ml-versegeospatial + CUDA + tidyverse

Pin by version tag (rocker/tidyverse:4.4.1), not by latest. The tag reflects the R version, so it’s easy to spot when you’re drifting off a known-good base. RSPM (the RStudio Public Package Manager) is the other reason the versioned images are stable: install.packages() resolves to fast, date-stamped binaries instead of compiling from source.

Image sizes scale with what they install: r-base is around 150 MB compressed, tidyverse is roughly 1 GB, verse lands around 2 to 3 GB, and geospatial is closer to 4 to 5 GB. If your image feels too big, the answer is usually that you’re using verse and you don’t need LaTeX. For Plumber or Shiny APIs, multi-stage builds (compile in rocker/r-ver:4.4.1, ship from rocker/r-base) can drop the final image by a factor of five or more. It rarely pays off for tidyverse workloads, where the runtime base is already most of the size.

Your first R Docker image

A minimal Dockerfile that runs an R script:

FROM rocker/r-ver:4.4.1
WORKDIR /project
COPY script.R script.R
CMD ["Rscript", "script.R"]

The script.R file lives in the working directory, alongside the Dockerfile. It is a short R file that prints the R version and shows where R looks for installed packages — that path is the proof the container is isolated from your host, because R will load libraries from inside the image rather than from your machine:

# script.R
message("Hello from R ", R.version.string, " inside Docker!")
print(.libPaths())

To build the image and run it once, use the two commands below. The first tags the image hello-r; the second starts a container from that image and removes it on exit (--rm). Because the base image is pinned to a specific R version, the result is the same on any host with Docker installed:

docker build -t hello-r .
docker run --rm hello-r

The output below shows the R version baked into the image, plus the two library paths reported by .libPaths(). If you see those paths inside the container but a different set on your host, that is the whole point — your host’s R libraries are not influencing the container’s R session:

# Hello from R 4.4.1 (2024-06-14) inside Docker!
# [1] "/usr/local/lib/R/site-library"
# [2] "/usr/local/lib/R/library"

Two things to notice. The R version is whatever’s baked into the image, not whatever’s on your laptop. And .libPaths() points at a single container-local library, which is the whole point: nothing leaks in from your host.

Reproducible R with renv in Docker

renv is the standard way to pin R package versions per project. The official renv-in-Docker pattern splits the COPY steps so the dependency layer caches independently from your code:

FROM rocker/r-ver:4.4.1

WORKDIR /project

# Copy renv metadata first -- these change less often than the R/ source
COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

RUN R -s -e "renv::restore()"

# Now copy the rest of the project
COPY . .

A matching .dockerignore is critical. Without it, the host’s renv/library/ gets copied in and shadows what renv::restore() writes, or fails outright on a Linux-to-Windows mismatch:

renv/*
!renv/activate.R
!renv/settings.json

You don’t need to install.packages("renv") on the base image. The .Rprofile sources renv/activate.R, which downloads renv on first run.

If renv::restore() fails with “package X was installed before R 4.x”, your base image’s R version doesn’t match the lockfile. Pin a matching R tag in the FROM line and rebuild.

Plumber API in Docker

Plumber turns R functions into HTTP endpoints. In Docker you need two things: pin a port and bind to 0.0.0.0, not localhost.

FROM rocker/r-ver:4.4.1

WORKDIR /app

COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json
RUN R -s -e "renv::restore()"

COPY . .

EXPOSE 8000
ENTRYPOINT ["R", "-e", "pr <- plumber::plumb('plumber.R'); pr$run(host='0.0.0.0', port=8000)"]

plumber.R sits next to the Dockerfile and defines the routes and the R functions behind them. Annotations starting with #* tell Plumber how to discover endpoints, and the @get /echo line exposes this function at /echo. The handler returns a small named list, which Plumber serialises to JSON:

# plumber.R
#* Echo a message back to the caller
#* @param msg The message to echo
#* @get /echo
function(msg = "") {
  list(
    echo = msg,
    r_version = sprintf("%s.%s", R.version$major, R.version$minor)
  )
}

docker build resolves every package from renv.lock and copies the project source into the image. The docker run command then maps port 8000 on your host to port 8000 inside the container, which is what makes the API reachable from your browser. The curl line at the end is a smoke test to confirm the bind address is set correctly:

docker build -t my-plumber-api .
docker run --rm -p 8000:8000 my-plumber-api
# In another terminal:
curl 'http://localhost:8000/echo?msg=hi'
# {"echo":"hi","r_version":"4.4.1"}

The host='0.0.0.0' argument is the part people forget. localhost only accepts connections from inside the container, so the API is unreachable from your host even though -p 8000:8000 looks right. You see a “connection refused” from curl on your laptop while docker ps cheerfully shows the port mapping. The Plumber hosting guide calls this out explicitly.

Shiny app in Docker

The Rocker Shiny image ships Shiny Server, which listens on port 3838. Apps go in /srv/shiny-server/.

A small app:

# app/app.R
library(shiny)

ui <- fluidPage(
  titlePanel("Hello from Shiny in Docker"),
  sliderInput("n", "Number of points:", 1, 100, 50),
  plotOutput("hist")
)

server <- function(input, output) {
  output$hist <- renderPlot({
    hist(rnorm(input$n), main = "Random normals")
  })
}

shinyApp(ui, server)

That is the entire app — a single app.R file. Rocker’s Shiny image already has Shiny Server installed and configured, so the Dockerfile just needs to drop the app into /srv/shiny-server/ and then run as the shiny user so the process does not run as root. Switching to a non-root user also avoids the file-permission headaches that come from writing inside the container:

FROM rocker/shiny:4.4.1
COPY app /srv/shiny-server/app
USER shiny

In development, bind-mount your source so edits show up on refresh without a rebuild. The -v flag maps a host directory into the container at the same path, which means changes to files under app/ on the host appear immediately inside the running container. That lets you iterate on UI or logic without a long rebuild loop, at the cost of reflecting host file permissions into the container:

docker run --rm -p 3838:3838 \
  -v "$(pwd)/app:/srv/shiny-server/app" \
  rocker/shiny:4.4.1

Open http://localhost:3838/app/ in a browser. For ShinyProxy or shinyapps.io deployments, the container has to keep listening on 3838, which is the convention those platforms expect.

Multi-service setups with docker-compose

Once you have a database, a cache, or a second API in the mix, hand-rolled docker run flags stop being a good idea. A docker-compose.yml is easier to read and reproduce:

services:
  api:
    build: .
    image: my-plumber-api
    ports:
      - "8000:8000"
    depends_on:
      - db
    environment:
      DATABASE_URL: "postgresql://ruser:rpass@db:5432/mydb"
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: ruser
      POSTGRES_PASSWORD: rpass
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

This file defines two services. The api service builds the Plumber image from the current directory and exposes port 8000 on the host. The db service runs Postgres 16 with credentials and a named volume for data. Service names resolve as hostnames, so the api container reaches the database at db:5432. Bring the whole stack up with one command:

docker compose up --build

depends_on waits for the database container to start, not for Postgres to be ready to accept connections. For that, add a healthcheck or a small wait loop in your Plumber code. Service names resolve as hostnames, so db works as the database host from inside the api container. For a fuller picture of running R services in production, see R in production 2026.

Common mistakes

A short list of bugs that come up again and again when people put R in containers for the first time.

Binding to localhost in Plumber. Covered above. Use host='0.0.0.0'. If you skip it, the API is unreachable from your host and docker ps looks fine.

Forgetting .dockerignore for renv. The host’s library gets baked in and silently overrides what renv::restore() writes. Symptom: a package that works on your machine throws “package X was installed before R 4.x” in CI.

R version mismatch with the lockfile. The renv.lock records the R version the lockfile was created with. If you bump R in the base image, renv::restore() can refuse to install. Match the R tag to the lockfile.

Missing system libraries. sf needs libgdal-dev, libgeos-dev, libproj-dev. xml2 needs libxml2-dev. rJava needs default-jdk. Rocker images include the common ones; if you base on raw ubuntu, you have to add them yourself. The renv “system dependencies” article lists the usual suspects.

Running as root. Rocker images ship a non-root rstudio user (uid 1000) and a shiny user. Don’t drop them. If you must bind a port under 1024, put a reverse proxy in front and keep the container on an unprivileged port (3838, 8000).

Time zone surprises. R reads TZ from the OS. If as.POSIXct() returns a value off by hours, the container’s clock and timezone disagree. Set ENV TZ=Etc/UTC (or whatever your project uses) in the Dockerfile. ENV LC_ALL=C.UTF-8 and ENV LANG=C.UTF-8 also save you from locale warnings on first run.

Build-time secrets in ENV. ENV values land in image layers and stick around in docker history. Use ARG (or BuildKit --secret) for tokens, GitHub PATs, and CRAN mirrors with credentials.

Image bloat. Combine apt-get update && apt-get install -y … && rm -rf /var/lib/apt/lists/* into a single RUN so the apt cache doesn’t survive into the final layer. Order your COPY commands so files that change less often go first; that’s what makes your rebuilds fast.

Wrapping up

Rocker + renv + a Dockerfile is the boring, reliable way to ship R Docker images. The pattern is the same whether you’re packaging a one-off analysis, a Plumber API, or a Shiny dashboard: pick the smallest base image that has your dependencies, pin it by version, let renv pin the R packages, and write a .dockerignore that doesn’t accidentally copy your host library. The first time a colleague runs your R Docker container and gets the same result you did, the setup will have paid for itself.

For the CI/CD side of things, the R devops 2026 article walks through GitHub Actions, registry publishing, and rolling deploys on top of the Docker setup described here.

See also