Affordable Shiny app hosting for 500 concurrent users

Cloud providers are no longer just offering traditional x86-based servers, ARM-based servers are now becoming a serious alternative. And for good reason: they’re often far more cost-effective and power-efficient than their x86 counterparts. For instance, we found a provider offering a server with 16 virtual cores and 32 GB of RAM for just €30 per month. To be realistic, most providers charge between €100 and €200 per month for such a server. These more expensive servers usually have less customers on a single server, improving the real life performance. In this post, we’ll put one of these budget-friendly ARM servers to the test. Of course, similar results can be achieved with an Intel or AMD server. The goal is to set up a production-grade ShinyProxy environment, designed to host a single public app, and see how many concurrent users it can realistically handle. (And yes, the title probably gives a hint already!).

Although this post focuses on a specific use case, the same principles also apply when running multiple apps with authentication.

Server selection

The first step is to pick a cloud provider and order a server. Choose a datacenter that’s located close to your end users to minimize latency and improve performance. Most providers also allow you to resize your server later by adding more CPU or RAM, so you can start small and scale up as demand grows. Make sure the server comes with a public IP address, since there is no need for a load balancer in this setup.

When it comes to the operating system, you can use any Linux distribution you prefer. In this tutorial, we’ll use Ubuntu 24.04. Once your server is provisioned, the next step is to connect via SSH. Usually your cloud provider provides documentation that explains how to do this.

As a final prerequisite, you’ll need a domain name. This makes it easier to access your server using a human-friendly address and is also necessary for configuring TLS. You can use either a root domain (e.g. example.com) or a subdomain (e.g. shinyproxy.example.com). In your domain’s DNS settings, create an A record that points the domain or subdomain to your server’s public IP address.

Installation

With the server up and running, the next step is to install the ShinyProxy Operator. This tool takes care of the ShinyProxy installation process as well as all the required side components, so you don’t have to manage them manually. For detailed instructions, follow the official documentation. For the best setup, make sure to enable TLS and the monitoring stack. At this point you should be able to login and start the demo app.

Inspecting the resource consumption of the app

With ShinyProxy deployed, you can deploy your app. Once again, the documentation contains everything you need to know. To keep things simple, you can build the Docker image on the server. For example, clone your Git repository with all your code on the server and build the image using Docker. This way, you don’t need a container registry. After you added the app to the ShinyProxy configuration, you can start it through ShinyProxy. In order to know how many concurrent users are supported by the server, it’s a good idea to first have a look at the resources consumed by the app. Start the app, perform some user actions and run the following command:

sudo docker stats

The output will be similar too:

The last line in the screenshot shows the container running our Shiny app. It uses about 125 MiB of RAM and very little CPU time. Keep in mind that we’re working with a small demo app, CPU usage will increase if your app processes more data or performs heavier computations. Most Shiny apps typically use less than 500 MiB of RAM, but some can consume multiple gigabytes, especially when loading large datasets. To keep our example realistic, we’ll assume each app requires 500 MiB of RAM. The other system components (including ShinyProxy) also consume some memory. In the screenshot, these consume about 1.5 GiB. ShinyProxy generally requires up to 2 GiB of RAM. To avoid degrading server performance, it’s a good practice to leave a few gigabytes free. For this setup, we’ll reserve 4 GiB for ShinyProxy and the server itself, leaving 28 GiB available for the Shiny containers. With each container using 0.5 GiB, this allows us to run up to 56 containers concurrently.

Optimizing the configuration

Originally, ShinyProxy created a new container for each user. While this approach is simple and improves security, it’s not ideal for hosting a public app with many users. Fortunately, since quite some time, ShinyProxy has excellent support for what we call pre-initialization and container-sharing. With this feature, a single container can serve multiple users simultaneously. Since the containers are pre-initialized, loading times are minimal. However, each Shiny container still has a limit on the number of users it can handle. For simple dashboards, you can typically expect about 20 concurrent users per container. In our example, we’ll assume 10 concurrent users per container. Given our 56 containers, this setup allows the server to handle up to 560 concurrent users. To stay on the safe side and avoid overloading the server, we’ll round this down to 500 concurrent users.

With these numbers in mind, it’s time to adjust the configuration. Let’s start with the app configuration.

proxy:
  specs:
    - id: my-app
      container-image: openanalytics/shinyproxy-demo
      seats-per-container: 10
      max-total-instances: 500
      minimum-seats-available: 500

The seats-per-container: 10 setting tells ShinyProxy that each container can handle 10 users or “seats”. To prevent overloading the server, we limit the total number of concurrent users to 500 with the max-total-instances: 500 setting. If more than 500 users try to access the app at the same time, they will see a message indicating that not enough capacity is available. Additionally, the minimum-seats-available: 500 setting ensures that ShinyProxy pre-creates 50 containers and keeps them running, so users can start the app immediately without waiting for containers to initialize.

In this example, we estimated the maximum number of concurrent users and limited the server to that value. Since we’re deploying only one app, it makes sense to create all containers at startup. However, if you are hosting multiple apps on a single server (which ShinyProxy fully supports), it’s better to start with just a few containers per app. ShinyProxy automatically creates new containers as users begin accessing the apps, ensuring resources are used efficiently.

While you are updating the configuration of ShinyProxy, add (or update) the following settings as well:

proxy:
  hide-navbar: true
  landing-page: SingleApp
  authentication: none
# ...
memory-limit: 2Gi

The first property hides the ShinyProxy navbar, which is often unnecessary when hosting a single public app. By setting landing-page: SingleApp, users are automatically redirected to the app when they visit the landing page. Since this is a public app, we also disable authentication. Finally, we limit the memory that ShinyProxy can use to ensure there is always enough RAM available for the Shiny apps.

After updating the configuration, the operator creates a new ShinyProxy instance with the updated settings. This process usually takes a few minutes. You might see a “forbidden” page if you previously logged in using a username and password. This happens only once when switching from authentication: simple to authentication: none. To fix it, simply clear the ShinyProxy cookies in your browser.

Testing the performance

As soon as ShinyProxy starts, it creates 50 new containers. When you launch your app, it loads almost instantly, thanks to the pre-initialized containers. We tested this setup with 500 browser sessions, and the system handled them effortlessly. Both ShinyProxy and the Shiny app responded quickly, and the server load remained minimal. Of course, actual performance depends on the resource consumption of your app, but ShinyProxy is designed to manage these variations efficiently. The setup demonstrates that even a single ARM-based server can easily manage hundreds of concurrent users, delivering robust, production-grade performance.

The first panel in the screenshot shows how long users wait before being assigned a seat, and the second panel shows the number of available seats. When we launched 500 sessions within just two minutes, the available seats dropped quickly. Even so, loading times stayed at only a few milliseconds, which makes it clear that ShinyProxy remains highly responsive during a sudden surge of hundreds of new sessions.

Once all apps are loaded, ShinyProxy’s CPU usage drops and remains low, as shown by the docker stats output:

Monitoring

Although the monitoring stack was enabled when ShinyProxy was first deployed, disabling authentication means that Grafana is no longer accessible through ShinyProxy. A simple workaround is to deploy a second ShinyProxy instance that still uses authentication. This private instance can also serve as a testing environment for new development versions of your app before deploying them to the public production server. To set this up, create a new configuration file in the input directory, for example private.shinyproxy.yaml, and add the following code:

server:
  servlet:
    context-path: /private

Be sure to update the proxy.realm-id, for example by setting it to private.

After deploying the server, you can access it at the same hostname as the main server, but under the /private/ sub-path. For instance, if your server is available at http://localhost/, the private instance is accessible at http://localhost/private/, and Grafana at http://localhost/private/grafana/. To view the logs and metrics of the public instance, you need to adjust the namespace variable in the dashboards.

Conclusion

With the introduction of the ShinyProxy Operator for Docker, setting up a production-grade ShinyProxy server has never been easier. This post demonstrates that only minimal configuration is needed to handle a large number of concurrent users. Expensive servers or clusters aren’t required, just a single server is sufficient. If you need to support more users, simply scale up the server: in most cases, doubling CPU and RAM roughly doubles the number of concurrent users. Compared to the resource usage of the Shiny apps themselves, ShinyProxy consumes very little CPU and RAM, so it rarely becomes a bottleneck even under heavy load.

Don’t hesitate to send in questions or suggestions and have fun with ShinyProxy!