16  Deployment

Up to this point, every app we’ve built runs on your local machine. You start it, you use it, you stop it.

Deployment is the process of putting your application on a server so that other people can use it. This chapter covers the most common ways to deploy an Ambiorix application.

Before we get into the specifics, one detail that matters in every deployment method: the port.

16.1 Port Resolution

Ambiorix determines which port to bind to in this order:

  1. The ambiorix.port.force R option – used by the Belgic load balancer. Do not set this yourself.
  2. The AMBIORIX_PORT environment variable.
  3. The port argument passed to Ambiorix$new() or $start().
  4. The SHINY_PORT environment variable – set by Shiny Server and shinyapps.io.
  5. A random available port.

In production, you almost always want to control the port explicitly – either via the port argument or an environment variable. A random port is fine for development, not for deployment.

16.2 Systemd

The simplest way to deploy on a Linux server: run the app as a systemd service.

  1. Create a service file at /etc/systemd/system/my-app.service:

    [Unit]
    Description=My Ambiorix Application
    After=network.target
    
    [Service]
    Type=simple
    User=deploy
    WorkingDirectory=/home/deploy/my-app
    ExecStart=/usr/bin/Rscript index.R
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target

    A breakdown of the key directives:

    [Unit]

    • Description: A human-readable name. Shows up in logs and systemctl status.
    • After=network.target: Start this service after the network subsystem is up.

    [Service]

    • Type=simple: systemd considers the service started as soon as ExecStart runs. Appropriate for long-running processes that don’t fork.
    • User: The user to run as. Never run your app as root.
    • WorkingDirectory: The process’s working directory. Relative paths in your R code resolve from here.
    • ExecStart: The command to run. Use absolute paths for both the interpreter and the script.
    • Restart=on-failure: Automatically restart if the process exits with a non-zero code. Other options: always, on-abnormal, no.
    • RestartSec=5: Wait 5 seconds before restarting. Prevents rapid restart loops.

    Install

    • WantedBy=multi-user.target: Start this service on normal boot. This is what makes systemctl enable work.
  2. Reload systemd to pick up the new file:

    sudo systemctl daemon-reload
  3. Start the service and enable it on boot:

    sudo systemctl start my-app
    sudo systemctl enable my-app
  4. Check that it’s running:

    sudo systemctl status my-app

To follow the logs in real time:

journalctl -u my-app -f

The -f flag tails the log, useful for debugging.

16.3 Docker

Docker packages your application and all its dependencies into a container. This makes deployments reproducible – the container runs the same way on your laptop, a staging server, and production.

The recommended approach uses {renv} for R package management and Docker Compose for running the container.

16.3.1 Prerequisites

  • Docker installed.
  • Docker Compose installed.
  • Your project bootstrapped with {renv}. If you haven’t done this yet, run renv::init() in your project directory.

16.3.2 Dockerfile

Create a file named Dockerfile at the root of your project:

FROM rocker/r-ver:4.5.0

RUN apt-get update && apt-get install -y \
  git-core \
  libssl-dev

WORKDIR /app
COPY . .

RUN rm -rdf renv/library
RUN R -e "renv::restore()"

EXPOSE 5000
CMD ["Rscript", "index.R"]

What each line does:

  • FROM rocker/r-ver:4.5.0: Base image with R 4.5.0. Change the version to match your project.
  • RUN apt-get ...: Installs system libraries needed by R packages. Add more as required. For example, {magick} needs libmagick++-dev.
  • WORKDIR /app: Sets the working directory inside the container.
  • COPY . .: Copies your project files into the container.
  • RUN rm -rdf renv/library: Clears the local library to ensure a clean restore.
  • RUN R -e "renv::restore()": Installs all packages from renv.lock.
  • EXPOSE 5000: Documents the port the app listens on. This should match the port in your R code.
  • CMD ["Rscript", "index.R"]: The command that runs when the container starts. Replace index.R with your entry point if different.

16.3.3 Build the Image

sudo docker build -t my-app .

16.3.4 docker-compose.yml

Create a docker-compose.yml file:

services:
  my-app:
    image: my-app
    ports:
      - "1028:5000"
    volumes:
      - ./data:/app/data
    restart: unless-stopped
  • ports: Maps port 1028 on the host to port 5000 inside the container. You access the app at localhost:1028.
  • volumes: Persists data between container restarts. Remove this if your app doesn’t write to disk.
  • restart: unless-stopped: Automatically restarts the container on crash.

16.3.5 Run

sudo docker compose up -d --remove-orphans

The -d flag runs in the background. To stop:

sudo docker compose down

16.3.6 Faster Rebuilds

Installing R packages is the slowest part of a Docker build. If you change application code but not dependencies, you don’t want to re-run renv::restore().

A two-stage build separates the dependency layer from the application layer:

Dockerfile.base – slow, cached:

FROM rocker/r-ver:4.5.0

RUN apt-get update && apt-get install -y \
  git-core \
  libssl-dev

WORKDIR /app
COPY .Rprofile .Rprofile
COPY renv.lock renv.lock
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

RUN R -e "renv::restore()"

Dockerfile – fast, thin:

FROM my-app-base

WORKDIR /app
COPY . .

EXPOSE 5000
CMD ["Rscript", "index.R"]

Build the base image once (or whenever dependencies change):

sudo docker build -f Dockerfile.base -t my-app-base .

Then rebuild the app image quickly:

sudo docker build -t my-app .

Only the COPY . . step runs. The package installation is cached in the base image.

16.4 Nginx Reverse Proxy

In production, you typically don’t expose your Ambiorix app directly to the internet. Instead, you put a reverse proxy in front of it.

Nginx is the most common choice. It handles SSL termination, serves static assets efficiently, and forwards requests to your app.

A basic config:

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

What this does:

  • The first server block redirects all HTTP traffic to HTTPS.
  • The second block listens on port 443 with SSL certificates (here from Let’s Encrypt).
  • proxy_pass forwards requests to the Ambiorix app running on port 5000.
  • The proxy_set_header directives pass the original client information through to your app.

Place this file in /etc/nginx/sites-available/, symlink it to /etc/nginx/sites-enabled/, and reload nginx:

sudo nginx -t
sudo systemctl reload nginx

This pairs naturally with the systemd or Docker approaches described above. Run the app with either method, and point nginx at the port it listens on.

16.4.1 WebSocket Support

If your application uses WebSockets, add these directives to the location block:

location / {
    proxy_pass http://127.0.0.1:5000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The Upgrade and Connection headers tell nginx to switch protocols from HTTP to WebSocket when the client requests it.

16.5 Shiny Server

Ambiorix apps can be deployed on Shiny Server. This is convenient if you already have Shiny Server running and want to serve Ambiorix and Shiny apps side by side.

Two things to keep in mind:

  1. Your entry point must be named app.R. Shiny Server looks for this file specifically.
  2. If your app is served at a sub-path (eg. localhost:3838/my-app), anchor tags using href="/about" will resolve to localhost:3838/about – outside your app. Prepend the app name: href="/my-app/about".

Shiny Server sets the SHINY_PORT environment variable before starting your app. Ambiorix picks this up automatically via the port resolution chain.

library(ambiorix)
library(htmltools)

home_get <- function(req, res) {
  html <- tags$h3("Ambiorix on Shiny Server!")
  res$send(html)
}

home_post <- function(req, res) {
  response <- list(
    code = 200L,
    msg = "An API too!"
  )
  res$json(response)
}

port <- Sys.getenv("SHINY_PORT")

Ambiorix$
  new(port = port)$
  get("/", home_get)$
  post("/", home_post)$
  start()

Configure Shiny Server to serve your project directory. The config file is typically at /etc/shiny-server/shiny-server.conf:

run_as deploy;

server {
  listen 3838;

  location / {
    site_dir /home/deploy/projects;
    log_dir /var/log/shiny-server;
  }
}

Then restart:

sudo systemctl restart shiny-server

Visit localhost:3838/my-app (where my-app is the name of your project’s directory).

16.5.1 shinyapps.io

The same approach works for shinyapps.io. No server configuration needed – just make sure your entry point is app.R and it reads SHINY_PORT:

port <- Sys.getenv("SHINY_PORT")

Ambiorix$
  new(port = port)$
  get("/", home_get)$
  start()

Then follow the standard shinyapps.io deployment steps.

16.6 Cleanup on Stop

In production, your app may hold resources – database connections, temp files, open file handles – that need to be cleaned up when the server stops.

Use the on_stop callback:

app <- Ambiorix$new()

app$on_stop <- function() {
  DBI::dbDisconnect(con)
  cat("Connections closed.\n")
}

This function runs when the app shuts down, whether from a SIGTERM, app$stop(), or a crash that triggers a restart.

17 Load Balancing

R is single-threaded. Even with async handlers, a single R process can only do so much. When your app needs to handle many concurrent users, you need multiple R processes behind a load balancer.

The load balancer sits in front of your app. It receives all incoming requests and distributes them across several backend R processes, each running a copy of your application.

17.1 Belgic

Belgic is a reverse proxy and load balancer built specifically for Ambiorix. It is to Ambiorix what Shiny Server is to Shiny. It also works with Shiny applications.

Belgic is written in Go. It spawns multiple R processes running your app and distributes requests across them using round-robin.

17.1.1 Install

Download a pre-built binary from the releases page, or install from source with Go:

go install github.com/ambiorix-web/belgic@latest

17.1.2 Configure

Generate a default config file:

./belgic config -p=config.json

The config file:

{
  "path": "/home/deploy/my-app",
  "port": "8080",
  "backends": "max",
  "attempts": 3
}
  • path: Directory containing your app. Belgic expects an app.R entry point.
  • port: The port Belgic listens on. This is the port users connect to.
  • backends: Number of R processes to spawn. "max" uses all available CPU cores.
  • attempts: How many times to try reviving a backend if it dies.

Set the BELGIC_CONFIG environment variable to point to your config:

export BELGIC_CONFIG="/home/deploy/config.json"

17.1.3 Run

./belgic start

Belgic starts the R processes, assigns each one a different port (via the ambiorix.port.force R option), and proxies incoming requests to them.

17.1.4 Daemonize

Run Belgic as a systemd service for production:

[Unit]
Description=Belgic Load Balancer

[Service]
ExecStart=/usr/local/bin/belgic start
Restart=on-abnormal
Type=simple

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start belgic
sudo systemctl enable belgic

17.1.5 Session State

Belgic uses round-robin. A user’s first request might go to backend 1, and their next request to backend 3. This means you cannot store session data in the R environment. It will not persist across requests.

Use cookies, a database, or another external store instead. This is good practice regardless of whether you use a load balancer.

17.2 Nginx + Docker Compose

If you prefer to manage load balancing yourself, you can run multiple Docker containers and put nginx in front of them.

17.2.1 docker-compose.yml

services:
  app-1:
    image: my-app
    expose:
      - "5000"
    restart: unless-stopped

  app-2:
    image: my-app
    expose:
      - "5000"
    restart: unless-stopped

  app-3:
    image: my-app
    expose:
      - "5000"
    restart: unless-stopped

  nginx:
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app-1
      - app-2
      - app-3
    restart: unless-stopped

Each app service runs a copy of your application. The expose directive makes port 5000 available to other containers on the same Docker network, but not to the host.

17.2.2 nginx.conf

events {}

http {
    upstream ambiorix {
        server app-1:5000;
        server app-2:5000;
        server app-3:5000;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://ambiorix;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

The upstream block defines the pool of backends. Nginx distributes requests across them using round-robin by default.

17.2.3 Run

sudo docker compose up -d

Your app is now available on port 80, load-balanced across three R processes.

To scale up or down, adjust the number of app services in docker-compose.yml and update the upstream block in nginx.conf accordingly.