Deployment

Lesson 10 — R Shiny

Lesson 10 of 11 Intermediate to advanced ~75 min

Learning objectives

  • Compare the deployment options and their trade-offs
  • Deploy to shinyapps.io and Posit Connect
  • Configure Shiny Server and understand its limits
  • Containerise an app with Docker for a reproducible deployment
  • Automate deployment from CI
  • Diagnose the failures that only appear after deployment

Options

Platform Cost Auth Scaling Best for
shinyapps.io Free tier, then per-hour Paid tiers only Automatic Demos, public apps, prototypes
Posit Connect Commercial licence LDAP/SAML/OAuth Configurable Enterprise, regulated environments
Shiny Server (open source) Free None One process per app Internal tools on a trusted network
Shiny Server Pro Commercial PAM/LDAP Multiple processes Legacy; Connect supersedes it
ShinyProxy Free (Java) LDAP/OpenID One container per user Container-native, good isolation
Docker + cloud Infrastructure cost Roll your own Whatever you build Full control, custom stacks
Posit Cloud Subscription Account-based Limited Teaching, sharing with collaborators

For pharmaceutical work the realistic shortlist is Posit Connect (if the organisation has it) or a container platform. Both give you a documented, reproducible environment, which is what a validation package needs.

Before you deploy

A pre-flight checklist that prevents most first-deployment failures:

# 1. Absolute paths?
grep -rn "C:/\|/Users/\|setwd(" R/ app.R

# 2. Every package declared?
renv::status()
renv::snapshot()

# 3. Runs from a clean session?
callr::r(function() shiny::runApp("."), show = TRUE)

# 4. Data files present and relative?
list.files("data")

# 5. Tests pass?
shiny::runTests(".")

# 6. Secrets in the environment, not the source?
grep -rn "password\|api_key\|secret" R/ app.R
WarningThe three most common deployment failures
  1. A path that only exists on your machine. readRDS("C:/data/adsl.rds"). Use relative paths from the app directory.
  2. A package used but never declared. It is installed on your laptop from an old project. renv::status() catches it.
  3. A file not committed. data/adsl.rds is in .gitignore, so the bundle does not contain it. Deploy from a fresh clone to check.

shinyapps.io

install.packages("rsconnect")

rsconnect::setAccountInfo(
  name   = "youraccount",
  token  = Sys.getenv("SHINYAPPS_TOKEN"),
  secret = Sys.getenv("SHINYAPPS_SECRET")
)

rsconnect::deployApp(
  appDir   = ".",
  appName  = "adsl-explorer",
  appTitle = "ADSL Explorer",
  account  = "youraccount",
  forceUpdate = TRUE
)

Control what is uploaded:

rsconnect::deployApp(
  appFiles = c("app.R", "R/", "data/adsl.rds", "www/"),
  # or
  appFileManifest = "manifest.txt"
)

Add a .rscignore to exclude things permanently:

tests/
data-raw/
*.Rproj
renv/library/
.git/
ImportantPatient data and public clouds

shinyapps.io is a multi-tenant public service. Do not deploy anything containing patient data, unblinded results, or anything else your organisation would not put on a public web server. Use synthetic data for demos.

Limits worth knowing: the free tier gives 25 active hours per month and 1 GB of RAM, apps sleep after 15 minutes of inactivity, and the maximum bundle is 1 GB.

Posit Connect

The enterprise option. Publish from RStudio (the blue Publish button) or from code:

rsconnect::addServer("https://connect.company.com", name = "company")
rsconnect::connectApiUser(
  account = "rgaduputi",
  server  = "company",
  apiKey  = Sys.getenv("CONNECT_API_KEY")
)

rsconnect::deployApp(
  appDir  = ".",
  appName = "abc101-review",
  server  = "company"
)

Connect gives you, out of the box:

  • Authentication integrated with the corporate directory
  • Per-app access control by user and group
  • Environment variables encrypted at rest, set per application
  • Scheduled reports as well as apps
  • Usage metrics — who opened what, when
  • Multiple R versions side by side
  • Process management — min/max processes, connections per process, timeouts

Runtime settings live in manifest.json (generated) and the app’s settings panel:

Setting Meaning Typical
Min processes Kept warm 1 for a frequently used app
Max processes Upper limit 3–10 depending on memory
Max connections per process Users sharing one R process 20
Idle timeout Before a process is reaped 5 min
Initial timeout Startup allowance 60 s

session$user and session$groups are populated automatically, which is what Authentication builds on.

Shiny Server

Open-source, self-hosted, no authentication.

# /etc/shiny-server/shiny-server.conf

run_as shiny;

server {
  listen 3838;

  location / {
    site_dir /srv/shiny-server;
    log_dir  /var/log/shiny-server;
    directory_index on;
  }

  location /abc101 {
    app_dir /srv/shiny-apps/abc101;
    log_dir /var/log/shiny-server/abc101;
    app_idle_timeout 300;
  }
}
sudo systemctl restart shiny-server
sudo tail -f /var/log/shiny-server/abc101/*.log

Open-source Shiny Server runs one R process per app, shared by all users. Consequences:

  • A long computation blocks every user of that app
  • One user’s crash takes down everyone’s session
  • No authentication whatsoever

Put it behind an authenticating reverse proxy, or use ShinyProxy instead.

Reverse proxy

server {
    listen 443 ssl;
    server_name apps.company.com;

    ssl_certificate     /etc/ssl/certs/company.crt;
    ssl_certificate_key /etc/ssl/private/company.key;

    auth_request /auth;

    location / {
        proxy_pass http://127.0.0.1:3838;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;   # websockets
        proxy_set_header Connection "upgrade";
        proxy_set_header Host       $host;
        proxy_set_header X-Forwarded-User $upstream_http_x_auth_request_user;
        proxy_read_timeout 20d;
        proxy_buffering off;
    }
}

The Upgrade/Connection headers are essential — Shiny uses websockets, and a proxy that does not forward the upgrade produces an app that loads and then immediately greys out.

Docker

The most portable and the most reproducible.

FROM rocker/shiny-verse:4.4.1

# System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    libcurl4-openssl-dev libssl-dev libxml2-dev libsodium-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /srv/shiny-server/app

# Install packages from the lockfile FIRST, so this layer caches
COPY renv.lock renv.lock
RUN R -e "install.packages('renv', repos = 'https://cloud.r-project.org')" && \
    R -e "renv::restore(repos = c(CRAN = 'https://packagemanager.posit.co/cran/latest'))"

# Then the app — changes here do not invalidate the package layer
COPY app.R    ./
COPY R/       ./R/
COPY data/    ./data/
COPY www/     ./www/

RUN chown -R shiny:shiny /srv/shiny-server

EXPOSE 3838
USER shiny

CMD ["R", "-e", "shiny::runApp('/srv/shiny-server/app', host = '0.0.0.0', port = 3838)"]
docker build -t abc101-review:1.2.0 .
docker run --rm -p 3838:3838 \
  -e DB_PASSWORD="$DB_PASSWORD" \
  abc101-review:1.2.0

Layer ordering matters: renv.lock is copied and restored before the application code, so editing app.R rebuilds in seconds rather than reinstalling every package.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3838:3838"
    environment:
      - DB_PASSWORD=${DB_PASSWORD}
      - APP_VERSION=1.2.0
    volumes:
      - ./logs:/var/log/shiny-server
      - study-data:/srv/data:ro
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3838"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  study-data:
TipPin everything
FROM rocker/shiny-verse:4.4.1     # not :latest

And use a dated Posit Package Manager snapshot so a rebuild six months from now installs the same package versions:

options(repos = c(CRAN =
  "https://packagemanager.posit.co/cran/2026-07-01"))

A container that rebuilds differently is not reproducible, which defeats the purpose.

Automated deployment

# .github/workflows/deploy.yaml
name: Deploy

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: r-lib/actions/setup-r@v2
      - uses: r-lib/actions/setup-renv@v2
      - run: shiny::runTests(".", assert = TRUE)
        shell: Rscript {0}

  deploy:
    needs: test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: r-lib/actions/setup-r@v2
      - uses: r-lib/actions/setup-renv@v2

      - name: Deploy to Connect
        env:
          CONNECT_API_KEY: ${{ secrets.CONNECT_API_KEY }}
          CONNECT_SERVER:  ${{ secrets.CONNECT_SERVER }}
        run: |
          rsconnect::addServer(Sys.getenv("CONNECT_SERVER"), "prod")
          rsconnect::connectApiUser(account = "ci", server = "prod",
                                    apiKey = Sys.getenv("CONNECT_API_KEY"))
          rsconnect::deployApp(appName = "abc101-review", server = "prod",
                               forceUpdate = TRUE)
        shell: Rscript {0}

Deploying only on a tag, and only after tests pass, means production always corresponds to a named, reviewable version. That is exactly the property a validation process needs.

Post-deployment problems

Symptom Likely cause Check
Greys out immediately Websockets blocked by the proxy Upgrade/Connection headers
“An error has occurred” Server error, details hidden options(shiny.sanitize.errors = FALSE) temporarily; read the logs
Works locally, not deployed Missing package or absolute path renv::status(), grep for paths
Slow first load Cold start, large global data Min processes ≥ 1; reduce startup work
Random disconnections Idle timeout, or proxy read timeout Raise proxy_read_timeout
Memory grows over time Leak, or per-session data accumulating Profile; check reactiveValues growth
Fine for one user, slow for ten Single process, blocking computation More processes; move work off the main thread
# Useful production options
options(
  shiny.sanitize.errors = TRUE,     # do not leak internals to users
  shiny.autoreload      = FALSE,
  shiny.maxRequestSize  = 100 * 1024^2
)

Health check endpoint, so a load balancer can tell whether the app is alive:

# In app.R
if (identical(Sys.getenv("HEALTHCHECK"), "1")) {
  quit(status = 0)
}

Common mistakes

Mistake Consequence Fix
Absolute paths App fails on the server Relative paths only
No renv.lock Different package versions Commit the lockfile
Deploying from a dirty working directory Untracked files silently included Deploy from a fresh clone
Secrets in the bundle Leaked to anyone with access Environment variables
:latest base image Non-reproducible rebuilds Pin the tag and the repo snapshot
No proxy websocket config App greys out Upgrade/Connection headers
Deploying without tests Broken production Test in CI before deploy
Patient data on a public host Reportable incident Synthetic data only

Exercise 10.1 — Containerise an app

Write a Dockerfile and docker-compose.yml for a Shiny app that uses renv, reads a database password from the environment, mounts study data read-only, and runs as a non-root user.

Show solution
# Dockerfile
FROM rocker/r-ver:4.4.1

LABEL org.opencontainers.image.title="ABC-101 Data Review"
LABEL org.opencontainers.image.version="1.2.0"

# --- System dependencies -------------------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
        libcurl4-openssl-dev \
        libssl-dev \
        libxml2-dev \
        libsodium-dev \
        libpq-dev \
        curl \
    && rm -rf /var/lib/apt/lists/*

# --- Non-root user -------------------------------------------------------
RUN groupadd -r shinyapp && useradd -r -g shinyapp -m -d /home/shinyapp shinyapp

WORKDIR /app

# --- Packages (cached layer) ---------------------------------------------
# Pin the repository snapshot so rebuilds are reproducible
ENV RENV_CONFIG_REPOS_OVERRIDE=https://packagemanager.posit.co/cran/2026-07-01
ENV RENV_PATHS_LIBRARY=/app/renv/library

COPY renv.lock renv.lock
RUN R -q -e "install.packages('renv', repos = '${RENV_CONFIG_REPOS_OVERRIDE}')" \
 && R -q -e "renv::restore(prompt = FALSE)"

# --- Application ---------------------------------------------------------
COPY app.R      ./
COPY R/         ./R/
COPY www/       ./www/
COPY tests/     ./tests/

RUN chown -R shinyapp:shinyapp /app

USER shinyapp
EXPOSE 3838

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD curl -fsS http://localhost:3838/ || exit 1

CMD ["R", "-q", "-e", \
     "shiny::runApp('/app', host = '0.0.0.0', port = 3838)"]
# docker-compose.yml
services:
  app:
    build:
      context: .
      args:
        APP_VERSION: "1.2.0"
    image: abc101-review:1.2.0
    container_name: abc101-review
    ports:
      - "127.0.0.1:3838:3838"      # localhost only; nginx terminates TLS
    environment:
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
      DB_HOST:     ${DB_HOST}
      APP_VERSION: "1.2.0"
      R_CONFIG_ACTIVE: production
    volumes:
      - type: bind
        source: /mnt/studies/abc101
        target: /data
        read_only: true            # the app must never write to source data
      - ./logs:/app/logs
    restart: unless-stopped
    stop_grace_period: 30s
    deploy:
      resources:
        limits:
          memory: 4G
          cpus: "2.0"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
# .env (never committed)
DB_PASSWORD=...
DB_HOST=db.internal
docker compose up -d --build
docker compose logs -f app

The decisions worth explaining:

  • Non-root user. A container process running as root that is compromised has root in the container, and depending on configuration, a path to the host.
  • 127.0.0.1:3838:3838 rather than 3838:3838. The container is reachable only from the host, so nginx (with TLS and authentication) is the only way in. Binding to 0.0.0.0 exposes an unauthenticated Shiny app to the network.
  • read_only: true on the data mount. The app has no business writing to source study data, and enforcing that in the mount is stronger than enforcing it in code.
  • Dated package snapshot. Without it, renv::restore() may fail in a year when a package version disappears from CRAN, or succeed with different transitive dependencies.
  • ${DB_PASSWORD:?...} fails the startup loudly if the variable is unset, instead of starting an app that mysteriously cannot connect.
  • Memory limit. A Shiny app with a runaway reactive can consume all host memory; the limit turns that into one restarted container instead of a dead server.

Recap

  • Choose Connect for enterprise/regulated, containers for full control, shinyapps.io for demos only
  • Never deploy patient data to a public multi-tenant service
  • Pre-flight: relative paths, renv::status(), clean-session run, tests pass
  • Open-source Shiny Server has no authentication and one process per app
  • Proxies must forward the websocket Upgrade/Connection headers
  • In Docker, restore packages before copying app code, and pin both the base image and the repo snapshot
  • Deploy from CI on a tag, after tests pass

Next: Production application design.

Back to top