Setup

I know only R, how do I set up my own server and my own website ?

My goal is to have the flexibility to create public facing services and personal projects using a permanent running server.

I already know I want to use freely any language, any DB, any API. I want to be able to code on server side and build shiny apps or REST apis of my own, secure access to some pages etc.

Let’s describe first how we got to put this page online, and maybe we’ll discuss fancier things later.

Again I’m no authority, really learning as I type.

IP, DNS, ports, domains…

We don’t need any provider to share a page or a service online, we can do it from our own computer.

Indeed we have an IP adress, we can open ports and exchange through those, but :

  • It’s not safe, we need to be on top of things security wise, and not do it from our every day computer
  • It should be online permanently, and with a constant IP
  • We won’t have a nice adress

To solve the first 2 points we need a host, to solve the last one we need a domain. Many hosts provide a domain if we subscribe.

Find a host and a domain

There are 3 main ways to go for webhosting, shared hosting, VPS, or standalone servers.

According to this comparative by LWS VPS seems to be the way for the flexibility I need.

LWS is a french provider with great reviews a generous offer for 10 euros per month (without any scammy price after the 1st month) and the domain name is offered.

We have one choice to make regarding the OS and preinstallation. To be ultra flexible chatGPT tells me to choose Debian 12 + SSH (not the default choice), I’ll follow along.

I’ll go with [m4i.be] for the domain name, 3 letters name and the url looks like “maybe”.

It is straightforward to pay easily through my bank app, and a few hours later the account is ready.

Access the server with SSH

Once I receive confirmation that it is setup I go the LWS domain management panel at https://panel.lws.fr/, choose the VPS service at the top, then click on the SSH keys icon to set the SSH connection. I give the name Antoine1 to my connection, then locally in my terminal I run :

cat ~/.ssh/id_rsa.pub

This retrieves the SSH key I had already generated on my laptop. I then copy and paste the output (ssh-rsa ...) the LWS form.

I validate my choice and reach a screen where it tells me that i have to wait for my key to be registered, it actually just takes a few minutes.

Install system dependencies

Locally in my terminal I can now run ssh root@31.207.34.184 and the following commands will be run on the server.

There I update my system and install required tools

apt update && apt upgrade -y

base dependencies, a bunch of common system tools that we’ll need directly or through R:

apt install -y sudo wget curl libcurl4-openssl-dev gnupg lsb-release software-properties-common apt-transport-https gdebi-core git ufw locales systemd rsync net-tools python3 python3-pip nginx libsqlite3-dev pandoc libfontconfig1-dev libfreetype6-dev libfreetype6-dev libfribidi-dev libharfbuzz-dev libxml-2.0

Open necessary ports

ufw allow 22
ufw allow 3838   # Shiny
ufw allow 8080   # VS Code Server
ufw allow 80     # HTTP (nginx)
ufw allow 443    # HTTPS (quand tu installeras certbot)
ufw --force enable

R installation

apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 'E298A3A825C0D65DFD57CBB651716619E084DAB9'
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys B8F25A8A73EACF41
echo "deb https://cloud.r-project.org/bin/linux/debian bookworm-cran40/" > /etc/apt/sources.list.d/cran.list
apt update
apt install -y r-base

Install R packages

Packages that need compilation can take forever to install from CRAN, but we can use the Posit package manager when our distribution is supported, it also allows us to use a snapshot date, which will go a long way for reproducibility.

The pak package tells us what is available :

# all supported distributions
pak:::ppm_platforms_internal() |> print(n = Inf)
## # A data frame: 32 × 6
##    name                 os      binary_url     distribution release binaries
##    <chr>                <chr>   <chr>          <chr>        <chr>   <lgl>   
##  1 centos7              linux   centos7        centos       7       TRUE    
##  2 centos8              linux   centos8        centos       8       TRUE    
##  3 rhel9                linux   rhel9          rockylinux   9       TRUE    
##  4 opensuse15           linux   opensuse15     opensuse     15      TRUE    
##  5 opensuse152          linux   opensuse152    opensuse     15.2    TRUE    
##  6 opensuse153          linux   opensuse153    opensuse     15.3    TRUE    
##  7 opensuse154          linux   opensuse154    opensuse     15.4    TRUE    
##  8 opensuse155          linux   opensuse155    opensuse     15.5    TRUE    
##  9 opensuse156          linux   opensuse156    opensuse     15.6    TRUE    
## 10 opensuse42           linux   opensuse42     opensuse     42.3    TRUE    
## 11 rhel7                linux   centos7        redhat       7       TRUE    
## 12 rhel8                linux   centos8        redhat       8       TRUE    
## 13 rhel9 (unused alias) linux   rhel9          redhat       9       TRUE    
## 14 sles12               linux   opensuse42     sle          12.3    TRUE    
## 15 sles15               linux   opensuse15     sle          15      TRUE    
## 16 sles152              linux   opensuse152    sle          15.2    TRUE    
## 17 sles153              linux   opensuse153    sle          15.3    TRUE    
## 18 sles154              linux   opensuse154    sle          15.4    TRUE    
## 19 sles155              linux   opensuse155    sle          15.5    TRUE    
## 20 sles156              linux   opensuse156    sle          15.6    TRUE    
## 21 xenial               linux   xenial         ubuntu       16.04   TRUE    
## 22 bionic               linux   bionic         ubuntu       18.04   TRUE    
## 23 focal                linux   focal          ubuntu       20.04   TRUE    
## 24 jammy                linux   jammy          ubuntu       22.04   TRUE    
## 25 noble                linux   noble          ubuntu       24.04   TRUE    
## 26 buster               linux   buster         debian       10      FALSE   
## 27 bullseye             linux   bullseye       debian       11      TRUE    
## 28 bookworm             linux   bookworm       debian       12      TRUE    
## 29 windows              windows <NA>           windows      all     TRUE    
## 30 macos                macos   <NA>           macos        all     TRUE    
## 31 manylinux_2_28       linux   manylinux_2_28 centos       8       TRUE    
## 32 internal             linux   internal       internal     all     TRUE

We see that there is one option for Debian 12, named “bookworm”.

And pak:::ppm_snapshots_internal() |> tail(1) gives use the last available snapshot, “2025-05-05” at time of writing.

https://pak.r-lib.org/reference/ppm_snapshots.html tells us how to format the repos url we need.

We type R in the terminal and then the following.

options(repos = "https://packagemanager.posit.co/cran/__linux__/bookworm/2025-05-05")
install.packages("pak")
pak::pak(c(
    "arrow", "bench", "constructive", "covr", "data.table", "devtools", "DT", "here", 
    "httr", "httr2", "igraph", "knitr", "languageserver", "lintr", "lubridate", 
    "pagedown", "pkgcache", "pkgdown", "profvis", "reactable", "readxl", "renv", 
    "reticulate", "rmarkdown", "rstudioapi", "rstudioapi", "rvest", 
    "shiny", "styler", "testthat", "tidymodels", "tidyverse", "tinytex", 
    "usethis"
))

The q() to exit R

Install VS Code Server

We can use vs code in 2 ways, either use VS Code Server through our web browser, or use VS Code on our laptop, connected through SSH.

The latter is generally more convenient for a single user always working from their own laptop, but it’s useful to have an instance available from anywhere. So let’s do it first.

curl -fsSL https://code-server.dev/install.sh | sh

Then we need to set a password, we call first:

mkdir -p ~/.config/code-server
nano ~/.config/code-server/config.yaml

Then make sure it looks like this:

bind-addr: 0.0.0.0:8080
auth: password
password: replace-this-with-your-password
cert: false

Create a systemd service for VS Code Server

cat <<EOF > /etc/systemd/system/code-server.service
[Unit]
Description=code-server
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/bin/code-server
Restart=always

[Install]
WantedBy=multi-user.target
EOF

And run the following:

systemctl daemon-reload
systemctl enable code-server
systemctl start code-server

Then VS code is accessible at this adress: http://31.207.34.184:8080/ (We’ll need to type our password there)

It seems to be common knowledge that VS Code and Safari don’t play well together, I found at least that copy and paste is broken, so we switched to Chrome and everything’s fine.

VS Code Extensions

In the left side bar we find the extension icon and we lookup and install “R Extension for Visual Studio Code”

Install Quarto

wget https://quarto.org/download/latest/quarto-linux-amd64.deb
apt install -y ./quarto-linux-amd64.deb

Install Shiny Server

Visit the following link to pick the latest link then run something similar to below

https://posit.co/download/shiny-server/

wget https://download3.rstudio.org/ubuntu-20.04/x86_64/shiny-server-1.5.23.1030-amd64.deb
gdebi -n shiny-server-1.5.23.1030-amd64.deb

Our shiny apps will be accessible through http://31.207.34.184:3838

More info

https://www.r-bloggers.com/2023/01/deploy-your-own-shiny-app-server-with-debian/

Install nginx

This will be our web server, we installed it above with all the system dependencies but now we need to start it, and make sure it starts by itself on reboot.

systemctl enable nginx
systemctl start nginx

The default nginx page will be at http://31.207.34.184

FTP

To easily transfer file from our server to our local machine it’s convenient to use a FTP client.

The 2 common options on mac are Cyberduck and Filezilla, we’ll go with cyberduck.

We install it and when we run it, define new collection as follows:

cyberduck

Write my html file

I go to VS code and create “/home/R/testproject/vibe.Rmd” and type this report, then knit it to create “/home/R/testproject/vibe.html”.

Serve a simple html file

Domain Side

The DNS maps domain/subdomain names to IP addresses.

In our cases we want

vibe.m4i.be    → 31.207.34.184

The DNS has no idea what “page” or “port” we’re using. It doesn’t handle routing to ports or URL paths, this will be handled on the server side.

For this I go to “DNS zone”, located in the panel in the m4i.be (domain) service, in the the “domain management” box.

There we:

  • Keep “A” as a type (It means IPv4 address, there are other record types)
  • Use “vibe” as a name
  • Use our server’s ip 31.207.34.184 as a value.

TTL can stay at its default value. It stands for ‘Time To Live’ and determines how long the IP address is cached before being refreshed and is not all that relevant for us.

Server side

configure the site

First we create a nginx config file.

The following command creates the file and open it for edition using “nano”, we can paste there the config that follows

nano /etc/nginx/sites-available/vibe.m4i.be
server {
    # listen on port 80, standard for HTTP.
    listen 80;

    # server block that will handle requests
    server_name vibe.m4i.be;

    # where the server will look for files to serve
    root /home/R/testproject;

    # default index file when only sudomain.domain.ext is provided
    index vibe.html;

    # serve if exists, otherwise return a 404
    location / {
        try_files $uri $uri/ =404;
    }
}

crl o , Enter, ctrl x

Note that this means in this case that every other file in the folder will be accessible by vibe.m4i.be/my_file.ext (though the listing is disabled by default so they wouldn’t be obvious to find from the outside), see below to work around this.

server {
    # listen on port 80, standard for HTTP.
    listen 80;

    # server block that will handle requests
    server_name vibe.m4i.be;

    # Allow access to only vibe.html
    location = /vibe.html {
        try_files $uri =404;
    }

    # Deny access to everything else
    location / {
        deny all;
    }
}

enable the site

Enable the site:

# create a symlink in /etc/nginx/sites-enabled/ pointing to the config we just defined
ln -s /etc/nginx/sites-available/vibe.m4i.be /etc/nginx/sites-enabled/
# test the nginx config for syntax errors and other issues
nginx -t  
# reload the nginx service, applying the changes made to the configuration
systemctl reload nginx

Serve a shiny app

Now we’re getting more ambitious and we want to use more technology.

We want to build :

  • A shiny app
  • Using a database
  • Fed by a llm through an API
  • Called periodically through a script called by CRON job
  • And have the whole thing password protected

But we also want it to be as simple as possible.

Start from a simple app

We already have some preinstalled with Shiny Server.

By default, apps are served from: “/srv/shiny-server/” and for our minimal example we can use “/srv/shiny-server/hello”.

Domain side

Register a subdomain in the DNS just as we dide for “vibe” in the DNS zone but register hello.m4i.be this time.

Server Side

Configure the site

As we did when serving our html page we create a configuration file and paste a config, which is a bit different this time.

nano /etc/nginx/sites-available/hello.m4i.be
server {
    # listen on port 80, standard for HTTP.
    listen 80;

    # server block that will handle requests
    server_name hello.m4i.be;

    location / {
        # forward all requests there
        # 127.0.0.1 refers to the local machine
        # Port 3838 is the default port for Shiny apps
        proxy_pass http://127.0.0.1:3838/sample-apps/hello/;
        # disables automatic redirection handling
        proxy_redirect off;
        # HTTP version for proxying, necessary for handling WebSocket connections that Shiny might use.
        proxy_http_version 1.1;
        # forward specific headers from the client to the backend service
        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;
    }
}

Anything after proxy_pass could be skipped, and I don’t understand much of it, but chatGPT recommands it so I comply.

enable the site

Same as what we did for vibe, just changing the name.

# create a symlink in /etc/nginx/sites-enabled/ pointing to the config we just defined
sudo ln -s /etc/nginx/sites-available/hello.m4i.be /etc/nginx/sites-enabled/
# test the nginx config for syntax errors and other issues
sudo nginx -t  
# reload the nginx service, applying the changes made to the configuration
sudo systemctl reload nginx

Troubleshooting

How to knit the current Rmd ?

Cmd Shift K

restart VS Code Server

e.g. If the R terminal windows doesn’t want to start anymore.

pkill -f -u $EUID /code-server
Part Meaning
pkill Sends a signal (default: TERM) to processes by name.
-f Match against the full command line, not just the process name.
-u $EUID Only kill processes owned by the current user (EUID = Effective UID).
/code-server The string to match in the process command line.

This only kills the process but it will restart automatically because it runs as a systemd service with restart policies. This was done in the “Create a systemd service for VS Code Server” in the installation steps above.