How to Set Up Quarto with Docker, Part 2: Dynamic Content

Quarto is an open-source scientific and technical publishing system built on Pandoc. It is a cross-platform tool to create dynamic content with Python, R, Julia, and Observable.

In the previous post about Quarto, we reviewed static hosting options with Docker containers, including single files and projects, such as books and websites.

How to Set Up Quarto with Docker, Part 1: Static Content
Learn how to work with Quarto inside a Docker container so that you can render and serve HTML documents and projects with ease.

Most of these options do not really require the hosting to be container based, because the rendered HTML can be published to static hosting sites, such as GitHub pages, or Netlify.

Even interactive documents that rely on widgets (Jupyter Widgets or htmlwidgets) or Observable JS can be hosted as static files. But just like with R Markdown, you can use Shiny for interactivity. This is the focus of Part 2.

Containerizing Interactive R Markdown Documents
R Markdown is a reproducible authoring format supporting dozens of static and dynamic output formats. Let’s review why and how you should containerize Rmd files.

Prerequisites

The code from this post can be found in the analythium/quarto-docker-examples GitHub repository:

GitHub - analythium/quarto-docker-examples: Quarto Examples with Docker
Quarto Examples with Docker. Contribute to analythium/quarto-docker-examples development by creating an account on GitHub.

You will also need Docker Desktop installed.

If you want Quarto to be installed on your local machine, follow these two links to get started: Quarto docs, and RStudio install resources.

Server: Shiny

You can specify Shiny as the engine to run the dynamic Quarto document. Here is the shiny/index.qmd file for the classic Old Faithful histogram with a slider input:

---
title: "Old Faithful"
execute:
  echo: false
format: html
server: shiny
---

```{r}
sliderInput("bins", "Number of bins:", 
            min = 1, max = 50, value = 30)
plotOutput("distPlot")
```

```{r}
#| context: server
output$distPlot <- renderPlot({
  x <- faithful[, 2]  # Old Faithful Geyser data
  bins <- seq(min(x), max(x), length.out = input$bins + 1)
  hist(x, breaks = bins, col = 'darkgray', border = 'white')
})
```

The YAML header options specify server: shiny and the context: server option is added to the second code chunk. This specifies this chunk to be run within the Shiny server. This behaviour is analogous to the R Markdown Shiny runtime.

Now on to the Dockerfile:

FROM analythium/r2u-quarto:20.04

RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /home/app
COPY shiny .
RUN chown app:app -R /home/app
USER app

EXPOSE 8080

CMD ["quarto", "serve", "index.qmd", "--port", "8080", "--host", "0.0.0.0"]

We use the analythium/r2u-quarto:20.04 parent image in the FROM instruction – this is the image we build in Part 1. The rest of the Dockerfile is pretty standard, create a user, copy the shiny folder contents into the image, set owner and user for the Docker runtime.

The render step will happen when the container is spin up. We use the quarto serve index.qmd --port 8080 --host 0.0.0.0 command to specify port and host IPv4 address. The quarto serve command will render the document  before serving. Note that in this example, we do not render the Quarto document up front.

Build and tag the image, then test:

docker build -f Dockerfile.shiny -t analythium/quarto:shiny .

docker run -p 8080:8080 analythium/quarto:shiny

In your browser, go to http://localhost:8080 to see the interactive Quarto document with the slider and histogram:

Quarto demo with Shiny served from a Docker container

If you can't kill the container with Ctrl+C, try getting the container ID with docker ps and then use docker kill $ID.

If you want to serve this document from R, you can use the https://CRAN.R-project.org/package=quarto: quarto::quarto_serve("index.qmd").

Shiny prerendered

Depending on the complexity of your document, rendering at the container launch time will significantly increase "cold start" time, i.e. the time you have to wait between starting the container and seeing the HTML in the browser.

To remedy this, we can render the document as part of the Docker build process. This is analogous to the R Markdown prerendered Shiny runtime (shinyrmd).

Let's try a different Quarto example this time showing K-means clustering with the Iris data set using a custom page layout. We have the same server: shiny option as before:

---
title: "Iris K-Means Clustering"
format: 
  html:
    page-layout: custom
server: shiny
---

```{r}
#| panel: sidebar
vars <- setdiff(names(iris), "Species")
selectInput('xcol', 'X Variable', vars)
selectInput('ycol', 'Y Variable', vars, selected = vars[[2]])
numericInput('clusters', 'Cluster count', 3, min = 1, max = 9)
```

```{r}
#| panel: fill
plotOutput('plot1')
```

```{r}
#| context: server
selectedData <- reactive({
    iris[, c(input$xcol, input$ycol)]
  })

clusters <- reactive({
  kmeans(selectedData(), input$clusters)
})

output$plot1 <- renderPlot({
  palette(c("#E41A1C", "#377EB8", "#4DAF4A", "#984EA3",
    "#FF7F00", "#FFFF33", "#A65628", "#F781BF", "#999999"))

  par(mar = c(5.1, 4.1, 0, 1))
  plot(selectedData(),
       col = clusters()$cluster,
       pch = 20, cex = 3)
  points(clusters()$centers, pch = 4, cex = 4, lwd = 4)
})
```

This is the corresponding Dockerfile:

FROM analythium/r2u-quarto:20.04

RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /home/app
COPY shiny-prerendered .
RUN quarto render index.qmd
RUN chown app:app -R /home/app
USER app

EXPOSE 8080

CMD ["quarto", "serve", "index.qmd", "--port", "8080", "--host", "0.0.0.0", "--no-render"]

There are two differences compared to the previous example:

  • we use quarto render index.qmd to render the document during the Docker build
  • then use the --no-render flag with quarto serve to avoid unnecessary rendering when the container is started

When we render the file, the UI elements get rendered, while the code chunks marked as context: server will wait until the rendered document is served. Read more about the render and server contexts.

Build and run the image:

docker build \
  -f Dockerfile.shiny-prerendered \
  -t analythium/quarto:shiny-prerendered .

docker run -p 8080:8080 analythium/quarto:shiny-prerendered

Then check you browser at http://localhost:8080:

Prerendered Quarto demo with Shiny served from a Docker container

That is it!

Conclusions

Serving interactive Shiny server based Quarto documents is very similar to serving R Markdown with Docker. The Quarto logic follows the prerendered Shiny runtime of R Markdown. But you need to use the quarto render and quarto serve commands with the right flags to avoid unnecessary rendering of the UI elements in your document.

I am sure interactivity in Quarto will soon include other options, like Shiny for Python and Shinylive. As things advance and more of the data-heavy computations can be pushed to the client (i.e. your browser), the need for a Dockerized setup will be less important. But until then, the Quarto+Docker examples from this 2-part series will provide a starting point for your adventures.

Further reading