Build commands, style, and PR conventions live in AGENTS.md. This file records the things that have actually gone wrong here, because none of them are visible from reading the Dockerfile.
Docker images of RStudio Server + R, published to Docker Hub (zatzmanm/rstudio)
and GHCR (ghcr.io/mjz1/rstudio-img) for R 4.3–4.6.
The images are consumed on an HPC cluster via Singularity, converted to
.sif and run by an Open OnDemand app (see mjz1/openondemandapps, the
rstudio_dev app). That downstream consumer is the reason most of the rules
below exist. Rolling tags 4.3–4.6 and latest are what the cluster syncs;
treat them as production.
Errors here have nowhere to surface. Every serious bug this repo has shipped was a silent failure that CI reported as green:
logger-type=syslog(inherited from rocker) meansrserverstartup errors are discarded — there is no syslog socket in a container. A fatal error looked exactly like "the port never opened."- A
RUNending incmd && break || echo ... && sleep 5exits 0 even whencmdfailed every time. It shipped an image with no TeX in it. xelatex --version | head -1exits 0 whenxelatexdoes not exist, unlesspipefailis set. Prefercommand -v foo && foo --version.find /as a non-root user silently skips/root(mode 0700). "Not found" is not the same as "not there."
When adding a build step, ask: if this failed, what would I see? If the answer
is "nothing," add an explicit check that fails the RUN.
The image is built as root but almost never run as root. Singularity runs it as you; the SIF filesystem is read-only regardless of what the permissions say.
- Anything installed into
$HOMEduring the build lands in/root(mode 0700) and is invisible to every real user. TinyTeX hit this. Install to/optandchmod -R a+rX. - Anything that needs to write at runtime cannot live inside the image.
TinyTeX's
TEXMFVAR/TEXMFCONFIGare redirected to~/.texlive/…for this reason; otherwiseluaotfloadcannot build its font cache and PDF rendering dies. - Config files the deb ships
0600 root:rootare unreadable.database.confis written explicitly with no secrets in it, mode0644, precisely so it can be world-readable. Do not "fix" that by tightening it. - The
secure-cookie-keymust not exist in the image. The deb's postinst regenerates it on every install; baked into a published image it is a shared secret that signs auth cookies for everyone who pulls that tag. Rocker deletes it; so do we. See the v1.1.1 security note in CHANGELOG.md.
The Dockerfile installs a newer rstudio-server deb over the rocker base. That
re-runs the deb's postinst, which undoes rocker's post-install fixups. If you
touch that step, re-read rocker's
install_rstudio.sh
and check nothing else was silently reverted.
A full matrix build is ~15 min/image. To test a behavioural change, build a two-line derived image and A/B it against the published one:
FROM ghcr.io/mjz1/rstudio-img:4.5
RUN <the thing you changed>podman build -f Dockerfile.test -t test:4.5 .
podman run --rm --user 1000:1000 test:4.5 <the check> # rootless, the real case
podman run --rm --user 1000:1000 ghcr.io/mjz1/rstudio-img:4.5 <the check> # controlAlways run the control. Twice during this repo's history a test "passed" for
both the fixed and broken image because it was failing for an unrelated reason
(a missing libR.so; a root-owned --tmpfs). A test that cannot fail proves
nothing — verify a new check fails on an image that lacks the fix.
PR validation runs a rootless smoke test (pr-validation.yaml, job smoke)
that asserts the invariants above: no baked cookie key, a readable
database.conf, logger-type=stderr, xelatex on PATH, the assistants
enabled, rserver --verify-installation exiting 0 as uid 1000, and Quarto
rendering an actual PDF. It builds one R version with load: true off the warm
gha cache. Add a check here whenever you fix a runtime bug; each of those lines
corresponds to a regression that shipped.
rsession treats an unrecognised option in rsession.conf as fatal. Two
consequences:
- Never add an option without checking the binary supports it:
grep -aq 'the-option' /usr/lib/rstudio-server/bin/rsession. The Dockerfile guards Posit Assistant this way, so a build pinned to an olderRSTUDIO_VERSIONdegrades rather than shipping unbootable sessions. - Read the rsession.conf
reference,
not the user guide.
posit-assistant-enableddefaults to1andallow-posit-assistantdefaults to0— the one that sounds like the switch is not the one that gates the feature.
4.3–4.6andlatestare rolling: the monthly scheduled rebuild moves them. Anything downstream that needs reproducibility must pin by digest.- Publishing only happens on a GitHub release (or
workflow_dispatch). Merging tomainruns PR validation, which builds but does not push. - Tag rules in
build_push.yamlare guarded againstrefs/heads/devso a dispatch fromdevcannot move the rolling tags. Keep new rules guarded. - Every tag rule runs once per matrix entry. Any rule not keyed on
matrix.r_versiongets pushed by all four jobs, and the last to finish wins — a race whose winner can differ per registry.type=ref,event=tagandmetadata-action's defaultflavor: latest=autoboth did this, and shipped alatestpointing at R 4.4 in v1.2.0.flavor: latest=falseis now set, and the bare release tag is keyed onLATEST_R_VERSION. After any release, check thatlatestandvX.Y.Zresolve to the same digest asvX.Y.Z-r<LATEST>, on both registries. - A published version is never overwritten. v1.1.0 was withdrawn rather than rebuilt in place: overwriting a tag remediates nothing (the bad image is already in caches) while destroying the ability to tell fixed from broken.
The image is large and every megabyte is paid on each docker pull, each
singularity pull, and each SIF conversion. nvidia-cuda-dev was 4.51 GB of a
9.57 GB apt footprint and nothing linked against it. Before adding a heavy
dependency, check whether anything actually needs it:
find "$R_LIBS" -name '*.so' | xargs objdump -p | grep NEEDED | grep -i <lib>One image serves CPU and GPU; there is no -cuda variant and the CI matrix does
not fork. GPU works via the host driver (--nv under Singularity, --gpus under
Docker) plus a framework that bundles most of its own CUDA (torch downloads a
CUDA-enabled backend with its own cuBLAS/cuDNN).
But the image DOES ship libcudart, and this was a hard-won correction to an
earlier "no CUDA toolkit at all" assumption. --nv gives only the driver
(libcuda.so), not the CUDA runtime. R torch's liblantern.so links the plain
soname libcudart.so.12 and resolves it from ldconfig; PyTorch bundles a
hash-mangled libcudart-<hash>.so.12 that lantern cannot see by name. So without
a system libcudart, cuda_is_available() is FALSE on a GPU node even though
nvidia-smi works — verified the hard way. Both majors (cuda-cudart-12-9,
cuda-cudart-13-0, ~5 MB) are installed unconditionally; the CI smoke test
asserts both sonames. This mirrors how rocker's retired CUDA images worked
(system runtime via ldconfig; see rocker-org/ml). Do not remove it thinking
"torch is self-contained" — it is not, for R.
WITH_CUDA=1 (default off) adds the fuller runtime (cuBLAS/cuFFT/… via
CUDA_RUNTIME_PACKAGES) on top, for packages that dlopen system versions beyond
cudart (Python TensorFlow, nvcc code). The full GPU stack (TensorFlow, GPU-BLAS,
RAPIDS) is scoped in issue #14. If you enable it, the driver caps the usable CUDA
version; pin accordingly.
The consumer supplies the driver and requests the device. For the OnDemand app
(mjz1/openondemandapps), that is --gres=gpu:N plus --nv, with --nv gated
on a runtime /dev/nvidia* probe — see that repo's CLAUDE.md.
The image ships Google Chrome so R packages that rasterise HTML can work:
webshot2, gt::gtsave("*.png"), pagedown::chrome_print() — all drive Chrome
through chromote. Issue #1 was gt failing to save a PNG because no browser
was present. Two things here are not obvious from the Dockerfile:
- Do not use Ubuntu's apt
chromium/chromium-browser. On noble it is a transitional snap; snaps do not run inside a container, so it "installs" and then fails at runtime. Google Chrome's own.debis a real binary and pulls its runtime deps (libnss3, fonts, …) via apt. - Rootless breaks Chrome's sandbox, so it must run
--no-sandbox. The sandbox needs a setuid helper or nested user namespaces; under Singularity neither exists, so Chrome cannot spawn its zygote and dies.chromoteonly adds--no-sandboxautomatically whenuid == 0, which never holds here. SoCHROMOTE_CHROMEis set to/usr/local/bin/chrome-headless-shim, a wrapper that always execs Chrome with--no-sandbox --disable-gpu --disable-dev-shm-usage(the last because/dev/shmis tiny in a container and Chrome crashes writing there). Everychromote-based package readsCHROMOTE_CHROME, so pointing it at the wrapper fixes all of them at once. If you ever bypass the wrapper (callinggoogle-chromedirectly), you own the flags.
The smoke test renders a data-URI screenshot as uid 1000 — a real rootless render, not just "the binary exists," because a present-but-unusable Chrome is exactly the silent failure this repo keeps shipping.