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:
- The
ambiorix.port.forceR option – used by the Belgic load balancer. Do not set this yourself. - The
AMBIORIX_PORTenvironment variable. - The
portargument passed toAmbiorix$new()or$start(). - The
SHINY_PORTenvironment variable – set by Shiny Server and shinyapps.io. - 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.
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.targetA breakdown of the key directives:
[Unit]
Description: A human-readable name. Shows up in logs andsystemctl status.After=network.target: Start this service after the network subsystem is up.
[Service]
Type=simple: systemd considers the service started as soon asExecStartruns. 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.
WantedBy=multi-user.target: Start this service on normal boot. This is what makessystemctl enablework.
Reload systemd to pick up the new file:
sudo systemctl daemon-reloadStart the service and enable it on boot:
sudo systemctl start my-app sudo systemctl enable my-appCheck that it’s running:
sudo systemctl status my-app
To follow the logs in real time:
journalctl -u my-app -fThe -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, runrenv::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}needslibmagick++-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 fromrenv.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. Replaceindex.Rwith 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-stoppedports: Maps port 1028 on the host to port 5000 inside the container. You access the app atlocalhost: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-orphansThe -d flag runs in the background. To stop:
sudo docker compose down16.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
serverblock redirects all HTTP traffic to HTTPS. - The second block listens on port 443 with SSL certificates (here from Let’s Encrypt).
proxy_passforwards requests to the Ambiorix app running on port 5000.- The
proxy_set_headerdirectives 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 nginxThis 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:
- Your entry point must be named
app.R. Shiny Server looks for this file specifically. - If your app is served at a sub-path (eg.
localhost:3838/my-app), anchor tags usinghref="/about"will resolve tolocalhost: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-serverVisit 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@latest17.1.2 Configure
Generate a default config file:
./belgic config -p=config.jsonThe config file:
{
"path": "/home/deploy/my-app",
"port": "8080",
"backends": "max",
"attempts": 3
}path: Directory containing your app. Belgic expects anapp.Rentry 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 startBelgic 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.targetsudo systemctl daemon-reload
sudo systemctl start belgic
sudo systemctl enable belgic17.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-stoppedEach 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 -dYour 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.