11 One App, Many Sites: Modules and Namespace Hierarchy
Chapter 10 showed the three-layer architecture: NGINX in front, Docker Compose running a pool of Shiny workers, and the app itself. That covered traffic. This chapter covers separation — how two products living in the same Shiny container stay isolated in code and in state.
The case study is two real ndexr sites:
Different products. Different users. Same Shiny container.
11.1 Three layers, three guarantees
| Layer | Tool | Guarantee |
|---|---|---|
| Routing | NGINX server_name |
Each subdomain has its own front door |
| Code | box::use() modules |
Each site loads its own module tree |
| State | storr namespace keys |
Each site reads and writes under its own prefix |
Each layer enforces what it owns. Get the hierarchy right and the boundaries take care of themselves.
11.2 Layer 1 — NGINX routes by subdomain
Both subdomains terminate at the same upstream. The relevant blocks from sites-enabled/:
server {
listen 443 ssl;
server_name geo.ndexr.io;
ssl_certificate /etc/letsencrypt/live/geo.ndexr.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/geo.ndexr.io/privkey.pem;
location / {
proxy_pass http://ndexr_app/;
include conf.d/common_proxy.conf;
}
}
server {
listen 443 ssl;
server_name treasure.ndexr.io;
ssl_certificate /etc/letsencrypt/live/treasure.ndexr.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/treasure.ndexr.io/privkey.pem;
location / {
proxy_pass http://ndexr_app/;
include conf.d/common_proxy.conf;
}
}
Two server blocks. Same proxy_pass http://ndexr_app/. NGINX reads the Host header, matches server_name, and forwards the request — Host rides through untouched via common_proxy.conf.
The Shiny app receives the request and can read the original host from session$clientData$url_hostname. That string is the seed for everything below.
11.3 Layer 2 — box::use modules are the unit of separation
Inside the app, the Host header selects which module mounts. There is no if (site == "geo") branching in shared code — the module is the branch.
# app.r
ui <- function(request) {
host <- shiny::getDefaultReactiveDomain()$clientData$url_hostname
site <- site_from_host(host) # "geo" or "treasure"
box::use(. / sites[mount_ui])
mount_ui(site)
}
server <- function(input, output, session) {
host <- session$clientData$url_hostname
site <- site_from_host(host)
box::use(. / sites[mount_server])
mount_server(site, input, output, session)
}Where sites/ looks like:
src/r/sites/
├── geo/
│ ├── ui.r
│ ├── server.r
│ └── ...
├── treasure/
│ ├── ui.r
│ ├── server.r
│ └── ...
└── mount.r # mount_ui() / mount_server() dispatch
box gives each directory a real namespace, not a convention. box::use(. / sites / geo / ui) only sees what geo/ui.r exports. It cannot reach into treasure/. That isn’t a code review rule — it’s enforced by the loader.
The Shiny module pattern from Chapter 9 (shiny::NS, moduleServer) handles ID namespacing inside each module. box handles file-level namespacing across them. Together they mean every input ID, every output ID, every helper function is scoped.
11.4 Layer 3 — storr namespaces isolate state
Code isolation is half the story. State is the other half. ndexr uses storr backed by SQLite for per-user state. The connection lives at src/r/connections/storr.r:
connection_storr <- function() {
box::use(. / sqlite, storr)
con <- sqlite$connection_sqlite(getOption("ndexr_sqlite_path"))
storr$storr_dbi("tblData", "tblKeys", con)
}Every read and write goes through con$get(id) / con$set(id, value). The id is where the hierarchy lives:
# In a geo module:
con$set("geo/users/freddy/preferences", prefs)
con$get("geo/maps/last_viewed")
# In a treasure module:
con$set("treasure/users/freddy/preferences", prefs)
con$get("treasure/inventory/2026-04-27")Same user, same SQLite file, same table — but the keys never collide because each module only writes under its own prefix. The / is not cosmetic. It is the contract between the module and the store.
A small wrapper enforces it:
namespaced_store <- function(site) {
con <- connection_storr()
list(
set = function(key, value) con$set(paste0(site, "/", key), value),
get = function(key) con$get(paste0(site, "/", key)),
list = function() grep(paste0("^", site, "/"), con$list(), value = TRUE)
)
}A geo module receives namespaced_store("geo") and physically cannot reach treasure/... keys. Cross-tenant leakage is impossible by construction, not by check.
11.5 Why the hierarchy matters
The / is doing three jobs at once:
- Isolation. A bug in the geo module cannot corrupt treasure’s state.
- Inspection.
con$list()filtered by prefix tells you exactly what one site has stored. Useful for debugging, exports, GDPR deletion. - Composition. Sub-prefixes like
geo/users/<id>/...give you a tree, not a flat map. You can wipe one user’s geo data without touching their treasure data:keys <- con$list(); con$del(grep("^geo/users/freddy/", keys, value = TRUE)).
Flat keys (geo_users_freddy_preferences) work for #1 but lose #2 and #3. The hierarchy is what makes the store operable a year from now, not just correct today.
11.6 Adding a third site
Say we want weather.ndexr.io. Following the layers:
- DNS — A record
weather.ndexr.io→ server IP. - TLS —
DOMAIN=weather.ndexr.io ndexr certissues a Let’s Encrypt cert via theacme.confchallenge route. - NGINX — copy
sites-enabled/treasure.ndexr.iotoweather.ndexr.io, swap theserver_nameand cert paths. - Code —
mkdir src/r/sites/weather/, addui.randserver.r, register them insites/mount.r. - State — nothing to do. The first
con$set("weather/...", ...)creates the namespace. - Deploy —
ndexr compose build && ndexr compose up.
No new container. No new database. No new deploy pipeline. The Shiny pool that already serves geo and treasure now serves weather too.
11.7 Summary
- NGINX gives you routing isolation — each subdomain has its own server block, its own cert, its own access rules.
box::usegives you code isolation — each site is a directory; the loader will not let one reach into another.storrnamespace prefixes give you state isolation — the/hierarchy is the boundary, and a small wrapper makes it impossible to cross.
Three layers. Three guarantees. One app pool serving all of them.
The reflex of “one app per product” is what makes Shiny deployments expensive. Modules are the unit. Subdomains are routing. Storage namespaces follow.