You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A practical demonstration of Docker optimization techniques for R Shiny applications, showing how multistage builds can reduce image size by 40-50% and improve build times through better layer caching.
7
+
A practical demonstration of Docker optimization techniques for R Shiny applications, showing how multistage builds with rocker/r2u can reduce image size by 25% and improve build times by 80-94% through better layer caching and binary package installation.
8
+
9
+
> **Blog Post:**[Read the full story](./blog-post.md) about optimizing Docker builds for a customer-facing R Shiny SaaS application running on Kubernetes.
8
10
9
11
## The Problem
10
12
@@ -59,21 +61,31 @@ docker run -p 3838:3838 shiny-app:optimized
# Recommended: rocker/r2u for binary packages (faster builds)
239
+
FROM rocker/r2u:24.04 AS builder
240
+
241
+
# Alternative: rocker/r-ver for source compilation
242
+
FROM rocker/r-ver:4.5.2 AS builder
243
+
244
+
# For Shiny Server (if not using Kubernetes)
245
+
FROM rocker/shiny:4.5.2 AS builder
215
246
```
216
247
248
+
**Note:** rocker/r2u provides pre-compiled binary packages, dramatically reducing build times compared to source compilation. Highly recommended for production use.
249
+
217
250
### Production Considerations
218
251
219
252
-**Health checks**: Add Docker health checks for production
@@ -223,7 +256,9 @@ shiny-docker-optimization/
223
256
224
257
## Related Resources
225
258
259
+
-**[Blog Post](./blog-post.md)**: Full story of optimizing Docker builds for production R Shiny SaaS
-**Deployment**: Running on Azure Kubernetes Service, serving customers globally
298
+
299
+
Read the [full blog post](./blog-post.md) for details on the production setup including base image management, cache-busting strategies, and CI/CD integration.
description: "How we reduced Docker build times by 80% and image sizes by 42% for a customer-facing R Shiny SaaS application using multistage builds with rocker/r2u"
6
+
description: "How we optimized Docker builds for a customer-facing R Shiny SaaS application by separating code changes (8-15 min) from dependency changes (40 min) and reduced image sizes by 42%"
7
7
---
8
8
9
-
# Optimizing R Shiny Docker Builds: From 40 Minutes to 10 Minutes
9
+
# Optimizing R Shiny Docker Builds: Warm vs Cold Build Strategy
10
10
11
-
This is my first time writing up a technical blog post, so bear with me. I'm going to share what I learned optimizing Docker builds for R Shiny apps, including all the mistakes I made along the way.
11
+
This is my first time writing up a technical blog post, so bear with me. I'm going to share what we learned optimizing Docker builds for R Shiny apps, including the things that broke along the way.
12
12
13
-
At Alamar Biosciences, I work on the NULISA Analysis Software (NAS) - basically a Shiny app for analyzing proteomics data. Unlike typical internal Shiny apps deployed with Posit Connect, NAS is a **customer-facing SaaS application** running on Azure Kubernetes Service (AKS). Our customers access it directly for analyzing their proteomics experiments, which means deployment speed, reliability, and scalability matter differently than for internal tools.
13
+
At Alamar Biosciences, I work on the NULISA Analysis Software (NAS) - a **large-scale, customer-facing SaaS application**for analyzing proteomics data, built with R Shiny and running on Azure Kubernetes Service (AKS). NAS is used by customers across academia and industry worldwide as a free cloud service. Unlike typical internal Shiny apps deployed with Posit Connect, NAS serves external customers directly, which means deployment speed, reliability, and scalability are critical for customer satisfaction and platform availability.
14
14
15
-
When I started, our Docker builds were painfully slow. Like, grab coffee, queue some villagers in AoE2, maybe respond to some emails slow. A simple one-line code change? Wait 40 minutes for Docker to rebuild the image. Our images were pushing 1.5GB compressed. Every deployment to Kubernetes felt like it took forever. When you're shipping features to customers and fixing production bugs, 40-minute build times kill your velocity.
15
+
Our Docker builds were taking 20-25 minutes. Every. Single. Build. It didn't matter if you changed one line of code or overhauled the entire data processing pipeline—Docker would reinstall all 200+ R packages from scratch. A simple bug fix? Wait 25 minutes. Testing a UI tweak? Another 25 minutes. Our images were pushing 1.5GB compressedto Azure Container Registry. When you're shipping features to customers and fixing production bugs, treating every build the same kills your velocity.
16
16
17
-
This post walks through how I cut Docker build times by 80% for code changes (40 mins → 8-15 mins) and reduced image sizes by 42% (1.5GB → 875MB). The optimization involves separating build from runtime using Docker multistage builds and leveraging rocker/r2u for binary package installation. More importantly, this post covers the things that broke along the way and how I fixed them.
17
+
This post walks through the optimizations we implemented in late 2025 that fundamentally changed how we build Docker images. The key insight: **separate code changes from dependency changes**. Now, the common case (code changes) builds in 8-15 minutes—a 60-68% improvement—while dependency updates take longer (40 mins) but happen infrequently. We also reduced image sizes by 42% (1.5GB → 870MB compressed in ACR). The optimization involves splitting stable CRAN dependencies into a base image, leveraging rocker/r2u for binary package installation, and properly structuring multistage builds. More importantly, this post covers the things that broke along the way and how we fixed them.
18
18
19
19
## The Problem: Slow, Bloated Docker Images
20
20
@@ -58,13 +58,13 @@ Our final images had everything needed to build the app, not just run it. All th
58
58
Here's what our Azure Kubernetes deployment looked like:
59
59
1. Push a code change (bug fix, new feature, etc.)
Those build times killed productivity. You'd push a fix, then switch to something else while waiting. By the time the build finished, you'd forgotten what you were even working on. It was like waiting for a Wonder to be built while your opponents are rushing you with trebuchets.
66
66
67
-
**Why this matters for SaaS:** With Posit Connect, you typically deploy once and iterate internally. With a customer-facing SaaS on Kubernetes, you're constantly shipping features, bug fixes, and updates. Fast build times directly impact how quickly you can respond to customer issues and ship improvements. A 40-minute build cycle means you can only deploy 2-3 times per day max. That's not acceptable for modern SaaS development.
67
+
**Why this matters for SaaS:** With Posit Connect, you typically deploy once and iterate internally. With a customer-facing SaaS on Kubernetes, you're constantly shipping features, bug fixes, and updates. Fast build times directly impact how quickly you can respond to customer issues and ship improvements. A 25-minute build cycle for every code change means you can only deploy a handful of times per day. That's not acceptable for modern SaaS development.
68
68
69
69
## The Solution: Multistage Docker Builds
70
70
@@ -198,24 +198,32 @@ Here are the real numbers from our GitHub Actions workflow on the demo repo:
|**Warm build** (code change only) |20-25 mins |8-15 mins |**60-68% faster**|
211
+
|**Cold build** (dependency change) |20-25 mins |40 mins |Slower, but infrequent|
213
212
214
213
**Note:** Our production setup includes additional optimizations beyond the scope of this post: automated base image rebuilds triggered by renv.lock hash changes, cache-busting strategies for custom package updates, and GitHub Actions runner cleanup for multi-stage builds. These advanced CI/CD integrations will be covered in a follow-up post.
215
214
216
-
**Note on image sizes:** Container registries (like Azure Container Registry, Docker Hub, GitHub Container Registry) store images in compressed format, which is why the "compressed/registry" sizes are significantly smaller than what you see locally with `docker images`. When you push an image to a registry, Docker compresses the layers, typically achieving 25-35% of the uncompressed size. This is important when considering deployment times and registry storage costs.
215
+
**Note on image sizes:** The sizes shown are **compressed sizes** as stored in container registries (ACR/GHCR). These are the sizes that matter for:
216
+
- Registry storage costs
217
+
- Network transfer time during push/pull
218
+
- Initial deployment speed to Kubernetes
217
219
218
-
**Note on warm builds:** Even single-stage Dockerfiles can have warm builds if you structure the layers correctly. The problem is that most single-stage setups use `COPY . .` early, which invalidates package installation on every code change. Our original single-stage Dockerfile had this issue, which is why warm builds were still slow.
220
+
Container registries compress images to about 25-35% of their uncompressed size. So the 870MB compressed NAS image is ~2.2-2.6GB when uncompressed on disk. The demo app achieves a 25% reduction in registry size, while our production NAS app sees a 42% reduction (1.5GB → 870MB in ACR).
221
+
222
+
**Note on warm vs cold builds:** The key optimization is **distinguishing between these two scenarios**. Our original setup treated every build the same—changing one line of code triggered a full 20-25 minute rebuild with all packages reinstalled. The optimized approach separates:
223
+
-**Warm builds** (90% of builds): Code changes only → 8-15 mins
224
+
-**Cold builds** (10% of builds): Dependency changes → 40 mins (longer, but comprehensive and cached)
225
+
226
+
Yes, cold builds are now slower, but they happen rarely (when you add/update packages). The common case (shipping code) is 60-68% faster.
219
227
220
228
The full CI/CD pipeline includes additional steps beyond the Docker build: running unit tests, extracting test results, publishing them to GitHub, security scanning, etc. That's why the end-to-end time is longer than just the Docker build. The unit testing integration will be covered in a separate blog post.
221
229
@@ -235,12 +243,16 @@ RUN R -e "renv::restore()" # So this has to run again. Every time.
235
243
236
244
**The right way:**
237
245
```dockerfile
238
-
COPY renv.lock . # Only changes when you add/remove packages
239
-
RUN R -e "renv::restore()" # Gets cached and reused for code changes
240
-
COPY app.R . # Changes all the time, but doesn't break cache above
246
+
COPY renv.lock . # Only changes when you add/remove packages (COLD build)
247
+
RUN R -e "renv::restore()" # Gets cached and reused for code changes (enables WARM builds)
248
+
COPY app.R . # Changes all the time, but doesn't break cache above (WARM build)
241
249
```
242
250
243
-
That simple reordering is the entire reason warm builds went from 8-10 minutes to 30 seconds. Put your stable stuff first, your frequently changing stuff last.
251
+
That simple reordering creates the warm/cold build distinction:
- **Cold build**: `renv.lock` changes → `renv::restore()` runs, takes 6-8 mins for the demo app (40 mins for NAS with 200+ packages)
254
+
255
+
Put your stable stuff first, your frequently changing stuff last.
244
256
245
257
## How This Works in Production
246
258
@@ -295,17 +307,7 @@ RUN --mount=type=secret,id=github_pat \
295
307
296
308
This keeps secrets out of your layers.
297
309
298
-
### 2. Parallel Package Installation
299
-
300
-
This is a small win but it adds up:
301
-
302
-
```dockerfile
303
-
RUN R -e "options(Ncpus = 4); renv::restore()"
304
-
```
305
-
306
-
Uses 4 cores to compile packages instead of 1. On a GitHub Actions runner, this shaved off another minute or two.
307
-
308
-
### 3. Pick the Right Base Image
310
+
### 2. Pick the Right Base Image
309
311
310
312
Use `rocker/r2u` for significantly faster package installation through binary packages. This is especially beneficial for large projects with many dependencies.
0 commit comments