Skip to content

Examples and best practices

This page shows what a project on the hub looks like, with one example per kind, followed by the habits that keep deploys predictable.

A minimal Shiny app

This is the app the hub's own smoke test deploys: one file, with ggplot2 recorded in its lock file.

# app.R
library(shiny)
library(ggplot2)

ui <- fluidPage(
  titlePanel("Hello from the hub"),
  sliderInput("n", "Number of observations", min = 10, max = 500, value = 100),
  plotOutput("hist")
)

server <- function(input, output) {
  output$hist <- renderPlot({
    ggplot(data.frame(x = rnorm(input$n)), aes(x)) +
      geom_histogram(bins = 30, fill = "steelblue", colour = "white") +
      theme_minimal()
  })
}

shinyApp(ui, server)

Next to it is the renv.lock that renv::snapshot() wrote. Running hub deploy in the directory builds and registers the app. The first deploy writes a hub.yaml, and that is where you set the title and the groups.

A project from the template

hub::new_app("sales", template = "shiny") creates a layout that works for apps larger than one file:

app.R          entry point: theme, layout, app-level inputs, module wiring; no logic
modules/       Shiny modules, one file per module
R_helpers/     logic with no Shiny in it, callable outside a running app
www/           served at the web root: styles.css, images
files/         read from disk by R, never fetched by the browser
data/          data files the app ships with
_brand.yml     colours and fonts, read by bslib
hub.yaml       how the app appears and runs on the hub
CLAUDE.md      the layout and conventions, for the coding agent

The agent reads the project's CLAUDE.md before it writes code in the project. The conventions for one kind of project therefore live in its template rather than in a hub-wide file. A customer's own templates in the overlay replace or add to the platform's.

An API that knows its caller

This plumber2 API, made from the plumber2 template, has one endpoint that only the finance group may use:

#* @get /figures
function(request) {
  if (!hub::in_group("finance", request)) plumber2::abort_forbidden()
  read_figures()
}

hub deploy publishes it at https://apps.hub.<your domain>/api/sales-api/. From a Shiny app or a pipeline on the hub, httr2::request(hub::api_url("sales-api")) calls it as the current user without any credential. A script outside the hub calls it with the key that the API's owner, a maintainer or an admin gets from hub apikey. On a hub set up to accept them, a token from the identity provider works as well. See APIs.

A pipeline and the app that reads it

Create the pipeline with hub::new_app("sales-etl", template = "targets"), write a _targets.R that writes its results to the pipeline's store, and add a schedule to hub.yaml:

name: sales-etl
schedule: "0 6 * * *"

The run at six every morning is incremental, as targets is on your machine, because the store is a persistent volume. The dashboard that shows the results lists the pipeline under uses:

name: sales
title: Sales dashboard
access-groups: [sales]
uses: [sales-etl]

A hub on a single host accepts uses: as it is. On a cluster it needs a storage class that can share the store (Cluster).

The dashboard reads the results with targets::tar_read(figures, store = hub::store("sales-etl")). In an IDE session this reads the current project's _targets, and on the hub it reads the mounted store. When a run fails, hub logs sales-etl shows the reason. On a hub with monitoring, the admins also get an alert mail.

Tools for the coding agents

The mcp template contains a tools.R that returns ellmer::tool() objects. Once the server is deployed, its tools appear in Claude Code and Posit Assistant for every colleague in its access-groups, from their next session start. The hub checks the caller's groups on every call, and the tools run with the server's own credentials, such as the secrets in its .Renviron. The typical use is a tool that queries the data warehouse, written once by the team that knows the schema. See MCP servers.

Best practices

  • Snapshot before deploying. Run renv::snapshot() or uv lock. The build installs exactly what the lock file lists, so a package that is installed but not recorded is missing on the hub.
  • Install inside the project. Use renv::install() with the project open. A package added in a plain session goes into the personal library and is not recorded.
  • Keep secrets in .Renviron. Never put them in the code or in hub.yaml. The deploy stores the file as the app's secret and leaves it out of the image. For a value that is not in the project, such as a password only an admin knows, use hub env set.
  • Commit, then deploy. The deploy records the commit it was built from. If there are uncommitted changes, it warns and marks the deploy in hub history. For an important app, require-clean: true in hub.yaml turns the warning into a refusal, and hub git bind deploys the app whenever a change is merged.
  • Read the user from the request. Use hub::user(), hub::groups() and hub::in_group(), or the X-SP-UserId and X-SP-UserGroups headers in Python, where the group names are upper case. Do not use the SHINYPROXY_* environment variables: they describe the container.
  • Build paths with here. Do not use relative paths, because the working directory in an RStudio session differs from the one in the deployed container.
  • Use system-packages only when hub sysreqs misses a library. The build resolves system libraries from the lock file. The list in hub.yaml is for anything it does not find.
  • Keep data out of the project directory. The upload includes everything in the directory, up to 200 MB. Store data on the home outside the project, in a pipeline's store, or in a bucket.
  • Use warm containers for the app people open first thing in the morning. With prestart: 1 the app opens at once. Every deploy then restarts the apps server (about a minute), so leave it off for apps you redeploy often.
  • Grant access to groups. access-groups refers to the groups the identity provider sends. Membership is managed in the identity provider, and the app follows it.