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:

  1. Isolation. A bug in the geo module cannot corrupt treasure’s state.
  2. Inspection. con$list() filtered by prefix tells you exactly what one site has stored. Useful for debugging, exports, GDPR deletion.
  3. 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:

  1. DNS — A record weather.ndexr.io → server IP.
  2. TLSDOMAIN=weather.ndexr.io ndexr cert issues a Let’s Encrypt cert via the acme.conf challenge route.
  3. NGINX — copy sites-enabled/treasure.ndexr.io to weather.ndexr.io, swap the server_name and cert paths.
  4. Codemkdir src/r/sites/weather/, add ui.r and server.r, register them in sites/mount.r.
  5. State — nothing to do. The first con$set("weather/...", ...) creates the namespace.
  6. Deployndexr 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::use gives you code isolation — each site is a directory; the loader will not let one reach into another.
  • storr namespace 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.