Deployment
Lesson 10 — R Shiny
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- A path that only exists on your machine.
readRDS("C:/data/adsl.rds"). Use relative paths from the app directory. - A package used but never declared. It is installed on your laptop from an old project.
renv::status()catches it. - A file not committed.
data/adsl.rdsis 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/
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/*.logOpen-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.0Layer 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:FROM rocker/shiny-verse:4.4.1 # not :latestAnd 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.internaldocker compose up -d --build
docker compose logs -f appThe 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:3838rather than3838:3838. The container is reachable only from the host, so nginx (with TLS and authentication) is the only way in. Binding to0.0.0.0exposes an unauthenticated Shiny app to the network.read_only: trueon 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/Connectionheaders - 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