Shiny in production on AWS: one private workspace per scientist, with login, plans and payments built in.
How we took DataViz Pro, a 31-module R Shiny application for plant science, from a laptop to a secure, paid, multi-user service in Mumbai. The full architecture, the real numbers and every step, including the mistakes worth avoiding.
Shiny apps are easy to build and hard to run for strangers.
Running shiny::runApp() on your laptop takes one line. Putting the same app in front of paying researchers around the world, safely, takes about forty decisions.
DataViz Pro is PlantNura's publication-ready visualisation workbench for crop science and breeding data: field trials, genomics, GWAS, heatmaps, population structure. It is a large app (31 modules, 395 pinned R and Bioconductor packages), its users upload unpublished research data, and some of them bring multi-gigabyte genotype matrices.
That set the bar. We needed every user to get their own isolated R process, a real sign-in, plans that unlock features and memory, payments in rupees and dollars, and a privacy promise we could prove: close the tab and your data is gone. All of it had to be affordable for a young company and run by a small team.
Isolation by design
ShinyProxy starts a fresh Docker container per user, sized by plan, and removes it when the session ends.
Identity is the plan
Amazon Cognito groups are the plans. A payment webhook moves people between groups; the app reads one variable.
Lean and serverless where it counts
One Graviton server for R; Lambda, DynamoDB and EventBridge for everything business-side. About US$145 a month in total.
Standing on two good shoulders.
Two write-ups have taught a generation of R users how to reach the cloud. Charles Bordet's Ultimate Guide to Deploying a Shiny App on AWS walks you from an EC2 instance to Shiny Server, Nginx, a domain, Let's Encrypt and a simple login. The AWS Architecture Blog's Scaling RStudio/Shiny using Serverless Architecture and AWS Fargate (2021) shows an enterprise reference design for data-science teams on ECS Fargate with EFS, WAF and Transfer Family.
Both are excellent at what they set out to do. Neither set out to run a commercial, multi-tenant Shiny product with sign-up, paid plans, per-user memory budgets and a provable data-deletion promise. That is the gap this post fills.
| Capability | Classic EC2 + Shiny Server guide | AWS reference (Fargate, 2021) | PlantNura blueprint (2026) |
|---|---|---|---|
| Audience | One app, a few known users | Internal data-science team | Public, paying researchers worldwide |
| Per-user isolation | ✕ shared R process (open-source Shiny Server) | ◐ Shiny containers scale; RStudio one container per service | ✓ one private container per user session, removed at the end |
| Sign-in | Nginx basic auth / Auth0 / shinymanager | Local Linux users (Cognito with RStudio Pro) | ✓ Cognito OpenID, email sign-up, branded pages |
| Plans & paywall | ✕ | ✕ | ✓ 4 plans → 4 ShinyProxy specs, feature locks in R |
| Payments | ✕ | ✕ | ✓ Razorpay hosted checkout (INR + USD), signed webhooks, subscriptions, passes, refunds |
| Memory per user | Whatever the box has left | Per task definition | ✓ 1 GB → 16 GB by plan, measured by load tests |
| Data privacy | Files persist on the server | Persistent EFS by design | ✓ session-only; containers without internet or AWS credentials |
| HTTPS / edge | Let's Encrypt on Nginx | Route 53, WAF, ACM, ALB | ✓ ACM + ALB, HTTP→HTTPS, path-based routing |
| Operations | Manual | CloudWatch, CloudTrail, backups | ✓ 7 alarms, weekly AMIs, budget alerts, one-command rollback |
| Cost for a small team | Lowest | Higher (NAT, EFS, Fargate per task) | ≈ US$145 / month all-in, credit-funded |
Twelve moving parts, one clear path.
The website stays on ordinary web hosting and only links to the app. Everything that needs R, identity or money lives in one AWS account in Mumbai. Click a number (or a step below) to trace that part of the flow.
What actually happens after "Start free".
The whole design is judged by one experience: a scientist clicks, and a few seconds later has a clean, private R session with exactly the features they paid for. Here is that path, end to end.
Ten phases, each one verified before the next.
We worked in strict phases with one rule: finish and prove a step before starting the next. Every phase below lists what we did, the configuration that matters, and the check that told us it was done. Test mode came first for everything involving money; live mode only after a written go-live checklist.
Foundations: a safe, cost-capped account
- Separate accounts from day one. An AWS Organization with a management account (billing, budgets, credits) and a dedicated workload account for the app. Nothing runs in the management account.
- Lock the root user with MFA and never use it for daily work. Daily sign-in goes through a named identity with MFA.
- Set the budget alarm before creating anything that costs money. Alerts at 50%, 80% and 100% of actual and forecast spend.
- Pin the region. A service control policy keeps work in Mumbai (
ap-south-1), which also matches our privacy policy: data is processed in India. - Tag everything (
Project,Env,Owner,ManagedBy) so the bill splits per product when the next app arrives. - Apply for AWS Activate early. Credits now cover the running costs for months.
Make the app cloud-ready, then containerise it
Most "it works on my laptop" failures are fixed before Docker. We ran a readiness audit on the Shiny code and fixed ten things, one commit at a time:
- No outside servers in the browser. CDN fonts and icon links removed, so the app works on a locked-down network and leaks nothing.
- Cap CPU per user.
parallel::detectCores() - 1would grab the whole server; it now reads a per-planDVP_CORESvalue. - No stray files. A null graphics device stops
Rplots.pdfappearing in the app folder. - Pin every package with renv: 395 packages, R 4.5.3, Bioconductor 3.22. Every build is identical.
- A tight
.dockerignorecut the build context from about 540 MB to 58 MB. - One environment variable decides the plan. Unset means full access (developer laptop); a typo falls back to the free plan, never to full access.
- Check the CPU architecture. All packages built on ARM, so we chose Graviton: more memory per dollar.
# The app never decides access by itself: the platform tells it. plan <- tolower(Sys.getenv("DVP_PLAN", unset = "full")) if (!plan %in% c("explorer", "researcher", "bigdata", "xl", "full")) { plan <- "explorer" # unknown value = free plan, never full access } # CPU per user comes from the platform too (default 2 cores) n_cores <- max(1L, min(parallel::detectCores() - 1L, as.integer(Sys.getenv("DVP_CORES", "2")), na.rm = TRUE))
# Simplified. Pinned R from renv.lock, built for Graviton (arm64). FROM rocker/r-ver:4.5.3 # system libraries for graphics, genomics packages and exports RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev libssl-dev libxml2-dev libmagick++-dev \ libfontconfig1-dev libharfbuzz-dev libfribidi-dev pandoc zip \ && rm -rf /var/lib/apt/lists/* ENV R_GC_MEM_GROW=0 # gentler memory growth on big data WORKDIR /app COPY renv.lock renv.lock RUN R -q -e "install.packages('renv'); renv::restore(prompt = FALSE)" COPY . . ARG DVP_VERSION ENV DVP_VERSION=${DVP_VERSION} RUN useradd -m shiny && chown -R shiny /app USER shiny # never run as root EXPOSE 3838 CMD ["R", "-q", "-e", "shiny::runApp('/app', host='0.0.0.0', port=3838)"]
# tag = app version + git commit, so every image is traceable TAG="v1.1.2-$(git rev-parse --short HEAD)" REPO="<account>.dkr.ecr.ap-south-1.amazonaws.com/datavizpro" aws ecr get-login-password --region ap-south-1 \ | docker login --username AWS --password-stdin "${REPO%%/*}" docker build --platform linux/arm64 --build-arg DVP_VERSION=$TAG -t $REPO:$TAG . # test every plan locally before pushing for p in explorer researcher bigdata xl typo; do docker run --rm -e DVP_PLAN=$p -p 3838:3838 $REPO:$TAG # check, then Ctrl-C done docker push $REPO:$TAG # never deploy :latest
Run it: server, ShinyProxy, load balancer, HTTPS
- Two security groups, nothing else open. The load balancer accepts 80/443 from the internet; the server accepts port 8080 only from the load balancer's security group. No SSH port at all: admin access is through Session Manager.
- The server: Amazon Linux 2023 on Graviton, encrypted disk, IMDSv2 required, termination protection, and an IAM role (no access keys on the machine). We started on a small instance and resized after load tests.
- ShinyProxy runs as a Docker container on the server and talks to the Docker daemon to start one app container per user.
- Certificate: ACM issues the certificate for
app.plantnura.com, validated by two CNAME records added in Hostinger DNS. Keep those records; ACM uses them to renew. - Load balancer: HTTPS on 443, HTTP redirected with a 301, idle timeout raised to one hour so long Shiny WebSocket sessions are not cut.
- DNS: a single CNAME
app→ the load balancer's address, in the same Hostinger panel as the website.
# Shiny keeps a WebSocket open; the default 60 s idle timeout drops quiet sessions aws elbv2 modify-load-balancer-attributes --load-balancer-arn $ALB_ARN \ --attributes Key=idle_timeout.timeout_seconds,Value=3600 # ShinyProxy answers /login with a redirect once sign-in is on: accept 2xx-3xx aws elbv2 modify-target-group --target-group-arn $TG_ARN \ --health-check-path /login --matcher HttpCode=200-399
https://app.plantnura.com shows the padlock; two browsers produce two separate containers; closing a tab removes its container.Login: Cognito groups are the plans
- A Cognito user pool (Essentials tier, free up to 10,000 monthly active users) with email-only sign-up, self-registration and deletion protection. Login pages carry PlantNura branding and links to the Terms and Privacy Policy.
- Groups:
researcher,bigdata,xl,admins. No group means the free Explorer plan, so a new user needs no extra step. - An app client with callback
https://app.plantnura.com/login/oauth2/code/shinyproxyand scopesopenid email. - The client secret lives in Parameter Store as a SecureString. ShinyProxy's start script reads it with the server's IAM role and passes it in as an environment variable. It is never in git, the image or a config file.
- One ShinyProxy spec per plan, all using the same image, each restricted with
access-groups. Users see only the workspace their plan allows.
proxy: authentication: openid openid: auth-url: https://<your-domain>.auth.ap-south-1.amazoncognito.com/oauth2/authorize token-url: https://<your-domain>.auth.ap-south-1.amazoncognito.com/oauth2/token jwks-url: https://cognito-idp.ap-south-1.amazonaws.com/<pool-id>/.well-known/jwks.json client-id: ${COGNITO_CLIENT_ID} client-secret: ${COGNITO_CLIENT_SECRET} # injected from Parameter Store at start roles-claim: cognito:groups # Cognito groups become ShinyProxy groups container-wait-time: 120000 # ms: allow a heavy R app to boot container-wait-timeout: 30000 # ms per readiness probe (default 5 s is too short) template-path: /opt/shinyproxy/templates/plantnura specs: - id: researcher display-name: DataViz Pro · Researcher container-image: <ecr-repo>/datavizpro:v1.1.2-<sha> access-groups: [researcher, admins] container-network: dvp-apps # internal network, no internet container-memory-limit: 2g max-lifetime: 240 # minutes container-env: DVP_PLAN: researcher DVP_UPGRADE_URL: https://app.plantnura.com/start # ... explorer (1g), bigdata (16g, max-total-instances: 1), xl
admins sees every workspace. Changing a group takes effect at the next sign-in.Payments: hosted checkout, signed webhooks, no servers
- Let the load balancer do the routing and the sign-in. ALB listener rules send
POST /webhooks/razorpayto the webhook Lambda (no auth, signature-checked instead), and/startand/accountthrough an authenticate-cognito action to the checkout Lambda. Everything else goes to ShinyProxy. - Checkout Lambda reads the plan from the link, creates a Razorpay order for one-off passes or a subscription for monthly plans, and opens Razorpay's hosted checkout. PlantNura never handles card or UPI details.
- Webhook Lambda verifies the HMAC-SHA256 signature on the raw body, checks the amount against the price book, ignores repeats (keyed on the payment ID), adds the user to the Cognito group and records the paid-up date in DynamoDB.
- Five events drive everything:
order.paid,subscription.charged,subscription.cancelled,subscription.halted,refund.processed. A cancelled monthly plan keeps access to the end of the paid month; a full refund removes the plan. - A daily expiry job (EventBridge Scheduler at 00:15 IST → Lambda) returns lapsed plans and passes to Explorer. A reminder email three days before a pass ends is rolling out with Amazon SES.
- Each Lambda has its own least-privilege IAM role: the console's default role is always swapped out.
import base64, hashlib, hmac def verified(event, secret: bytes) -> bool: # Sign the RAW body exactly as received; parsing JSON first breaks the signature body = event.get("body") or "" raw = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode() expected = hmac.new(secret, raw, hashlib.sha256).hexdigest() got = event["headers"].get("x-razorpay-signature", "") return hmac.compare_digest(expected, got) # constant-time compare # secret = Parameter Store SecureString, fetched with decryption by the Lambda's role
# An ALB target Lambda must return statusDescription as "code + reason". # A bare "400" makes the load balancer answer 502 Bad Gateway. def reply(code: int, text: str, body: str = "", ctype="text/plain"): return { "statusCode": code, "statusDescription": f"{code} {text}", # e.g. "400 Bad Request" "isBase64Encoded": False, "headers": {"Content-Type": ctype}, "body": body, }
MODE switch that selects the matching keys and plan IDs.Plan limits at runtime
- Memory per container by plan (see section 6), enforced by Docker, so one heavy session cannot starve others.
- Session caps: free sessions share their own pool, Big Data runs one session at a time, and the server has an overall cap, so free users can never crowd out paying ones.
- Idle and lifetime limits: 15 minutes idle; longest session 1 hour on Explorer and 4 hours on paid plans.
- In-app upgrade buttons point to
/start, so "Unlock" in the app and "Buy" on the website share one checkout. - Usage events (session start and stop, plan, duration; never data) go to a local log so we can report Big Data hours before enforcing them.
Data and privacy: make the policy true
- Uploads exist only inside the user's container. No shared disk, no bucket, no database of user files.
- App containers have no internet. They run on a Docker network created with
--internal; only ShinyProxy is on both networks. - Containers cannot reach AWS credentials. IMDSv2 with a hop limit of 1 means the server can read its role, but a container (one extra hop) cannot.
- Logs hold events, not data. Lambda logs keep 30 days; admin shell sessions are recorded for 90 days; usage records are trimmed to 12 months automatically.
- Then we rewrote the privacy policy to say exactly this, and nothing we could not prove.
# 1. An internal network: containers on it have no route to the internet docker network create --internal dvp-apps docker network connect dvp-apps shinyproxy # ShinyProxy bridges both networks # 2. Containers cannot borrow the server's IAM role aws ec2 modify-instance-metadata-options --instance-id $INSTANCE_ID \ --http-tokens required --http-put-response-hop-limit 1 # 3. Prove it from inside a user container (both must fail) curl -m 5 https://example.com curl -m 5 http://169.254.169.254/latest/api/token -X PUT
Load tests before publishing any limit
We generated synthetic files from 25 MB to 4 GB, ran read, box plot, scatter and correlation in each workspace size, and recorded peak memory from the container's cgroup. The results changed two decisions: the server size and the Big Data file limit. Details in section 7.
Go-live: a checklist, not a feeling
- Terms, Privacy, Refund & Cancellation pages updated to match the plans exactly; prices identical on the website and in Razorpay (INR with GST, USD).
- Live keys and webhook secret entered by the owner, directly in the console; never in chat, email or code.
- A real purchase with a real card. It surfaced a live webhook-secret mismatch within minutes, fixed by rotating the secret in both places. A second real purchase proved the full chain.
- Alarms wired to email; a written rollback plan, including a one-rule "pause sales" switch on the load balancer that returns a fixed maintenance response for checkout.
Operations: boring on purpose
Weekly server images, memory and disk metrics with alarms, a monthly cost review and a fixed release routine. See section 10.
One image, four workspaces.
Every plan runs the same Docker image. What differs is what ShinyProxy gives the container: a memory budget, a session cap, a lifetime, and the value of DVP_PLAN, which the R code uses to lock or unlock modules and upload sizes. Changing a plan never needs a new build.
Explorer
- 5 MB per file
- Upload, preview, box & bar plots
- Shared free pool
- Session up to 1 h
Researcher
- 60 MB per file
- All 31 modules, full exports
- Shared paid capacity
- Session up to 4 h
Big Data
- Files up to 2 GB
- All modules + big datasets
- 1 session at a time
- Session up to 4 h
XL
- Institutes and special cases
- Tuned per agreement
- 1 session at a time
- Capacity set per agreement
Bars show container memory relative to the largest workspace (16 GB). Explorer users keep full access to their own data in the open modules; the other modules show a preview with an Unlock button.
Measure first, publish limits second.
File limits on a pricing page are promises. Before we published any, we loaded synthetic data files (19 columns, about 146 bytes per row) into each workspace size, ran read, box plot, scatter and correlation, and read the container's peak memory from its cgroup. Two simple rules came out of it.
R holds the parsed table, working copies and plot objects at once. Plan for three and a half times the file, plus the app itself.
Generous headroom for heavier analyses. A 16 GB workspace → 2 GB files, which is exactly the Big Data limit we published.
One line of R (data.table::fread, with read.csv as fallback) made uploads about 8× faster for every user.
Eight layers between the internet and a researcher's data.
Unpublished field and genomic data is valuable. Our privacy promise is short ("your data lives only in your session") so every layer below exists to keep it true and checkable.
ACM certificate on the load balancer; HTTP redirected; only ports 80 and 443 face the internet.
The server accepts traffic only from the load balancer's security group. No SSH port; admin access via Session Manager, every session recorded.
Every app request and every checkout page requires a signed-in user. Plans are group memberships, not flags in a browser.
Card and UPI details stay with Razorpay. Webhooks are HMAC-verified, amount-checked and idempotent; a bad signature raises an alarm.
Separate process, memory cap and temporary storage per user. Non-root user inside the image.
App containers sit on an internal Docker network: nothing can be sent anywhere, including to any AI service.
IAM roles only, least privilege per function. Secrets in Parameter Store (SecureString). IMDS hop limit 1 keeps containers away from the server's role.
Container removed at session end. Logs hold events, never values or file contents, and expire on a schedule.
About US$145 a month, almost all of it one server.
Because the business logic is serverless (Lambda, DynamoDB, EventBridge, Cognito's free tier), the bill is dominated by the machine that runs R. Costs scale with people online at the same time, not with how many accounts exist.
| Item | Monthly | Why so low |
|---|---|---|
| Amazon Cognito | $0 | Essentials tier free up to 10,000 monthly active users |
| Lambda × 3 | ≈ $0 | Runs only on a click, a payment or once a day |
| DynamoDB | ≈ $0 | On-demand, a few hundred tiny records |
| EventBridge Scheduler | $0 | One run a day |
| NAT gateway, EFS, WAF | not needed | No outbound app traffic, no shared files by design |
Ship in minutes, roll back in one line.
Each release is an image tagged with the app version and the git commit, so what runs in production is always traceable to code. Version 1.1.1 went out with 876 automated regression checks passing; version 1.1.2 followed the same routine on 21 October.
# on the server, via Session Manager (no SSH) NEW="v1.1.2-<sha>"; OLD="v1.1.1-<sha>" docker pull $REPO:$NEW # 1. fetch the image cp /opt/shinyproxy/application.yml \ /opt/shinyproxy/application.yml.before-$NEW # 2. keep the old config sed -i "s/datavizpro:$OLD/datavizpro:$NEW/g" /opt/shinyproxy/application.yml # 3. switch all plans docker restart shinyproxy # 4. new sessions use NEW
# one command back to the previous release
cp /opt/shinyproxy/application.yml.before-v1.1.2-<sha> /opt/shinyproxy/application.yml \
&& docker restart shinyproxy
Seven alarms, one inbox
| Alarm | Fires when |
|---|---|
| App down | Load balancer sees no healthy ShinyProxy target |
| Server status check | EC2 system or instance check fails |
| Server memory high | Memory ≥ 85% for 5 minutes (CloudWatch agent) |
| Server disk high | Root disk ≥ 80% |
| Webhook bad signature | Any payment message fails verification |
| Webhook errors | Webhook Lambda throws |
| Checkout errors | Checkout Lambda throws |
Plus: a weekly server image kept four deep, a monthly cost review against the budget, and a short list of launch metrics we watch (sign-ups, free-to-paid, Big Data hours, peak concurrent sessions, cost per active user).
Ten things that cost us an hour so they need not cost you one.
None of these are in the getting-started guides. Each one showed up as a confusing symptom in production or testing.
1 · The readiness probe was shorter than R's start-up
Our first page takes about 11 s; ShinyProxy's per-probe timeout defaults to 5 s. Set container-wait-timeout: 30000 and a generous container-wait-time.
2 · Login turned the health check red
With OpenID on, /login answers with a redirect, not 200. An ALB with no healthy targets fails open, which hid it. Accept 200-399, and the "app down" alarm becomes meaningful.
3 · ALB wants a full status line
Lambda targets behind an ALB must return statusDescription: "400 Bad Request". A bare code is treated as a malformed response.
4 · Raise the load balancer idle timeout
Shiny keeps one WebSocket open. The ALB default of 60 s cuts users who are reading a plot. We use 3,600 s.
5 · Set the IMDS hop limit to 1
With IMDSv2 and one hop, the server can use its role but Docker containers (one hop further) cannot. Test it from inside a container.
6 · Test and live plans are different worlds
Razorpay plan IDs belong to one mode. Keep both sets of IDs and keys behind a single MODE switch in code.
7 · Rotate the webhook secret in both places at once
Generate a new random secret, paste it into Razorpay and Parameter Store, redeploy. Retries of the old event keep failing for about a day; that is expected.
8 · ShinyProxy caches assets at start
Custom templates are re-read live, but files under /assets are loaded into memory at start. Swap the file, then restart ShinyProxy.
9 · You are in the wrong region
With a region-lock policy, any console page outside Mumbai fails with an SCP error. Check the top-right corner first.
10 · Replace the console's default Lambda role
Creating a function in the console can attach a broad role. Give each function a dedicated role that can touch only its own parameters, table and group actions.
Built to grow without a rewrite.
Nothing in the stack stores user data on the server, so adding capacity is safe. Login, plans, payments and the image stay exactly the same at every stage; only where containers run changes.
One Graviton server
ShinyProxy + Docker behind the ALB. Room for a 16 GB Big Data session alongside several free and Researcher sessions.
Bigger or second server
Triggered when memory sits above ~70% at peak. A type change or a second target in the same group.
Containers on ECS
ShinyProxy (3.1+) can start user containers on Amazon ECS, so AWS adds capacity with demand. Configuration change, same image.
Second region
A copy near users in Europe or the Americas, if latency data asks for it. Decided from real numbers.
Have a Shiny app that deserves real users?
We built this for our own product, DataViz Pro, and we reuse the same blueprint for our next apps. We can do it for yours: from a folder of R scripts to a secure, paid, monitored service your users can sign up to.
References
- Panda, C., Hsieh, M. & Mukwevho, M. (2021). Scaling RStudio/Shiny using Serverless Architecture and AWS Fargate. AWS Architecture Blog.
- Bordet, C. The Ultimate Guide to Deploying a Shiny App on AWS.
- Open Analytics. ShinyProxy configuration reference (property names and units checked 18 Oct 2026).
- Amazon Web Services. Lambda functions as targets of an Application Load Balancer.
- Amazon Web Services. Authenticate users using an Application Load Balancer.
- Razorpay. Validate and test webhooks.
- Hussain, W. (2026). DataViz Pro: publication-ready data visualization for plant science (Version 1.1.2) [Web application]. PlantNura Consulting & Solutions LLP. plantnura.com/tools/datavizpro.html
Figures describe the production system as of 21 October 2026. Costs are AWS list prices for the Mumbai region before credits and taxes; your numbers will differ with region, instance type and usage. Account identifiers, resource IDs and secrets are intentionally omitted.
AWS and the names of AWS services are trademarks of Amazon.com, Inc. or its affiliates. This article is independent and not endorsed by AWS.