← back to blog

How I Host Apps from my Raspberry Pi

6 April 2024

So I’ve recently become the proud owner of a Raspberry Pi 5, a microcomputer that fits in the palm of your hand. It’s initial targeted use was for educational purposes in computer science, but it’s since been widely adopted by hobbyists with varying interests. I got mine to work with some data science projects, educational projects, and to host a variety of apps that currently live on various platforms. In this post I’d like to through how I host my Flask and Shiny apps on my Raspberry Pi from development to putting them online for anyone to access.

Step 1 - Setup

I found this tutorial very useful for setting up a “headless” Raspberry Pi

My Raspberry Pi is in a “headless” setup so I ssh into my Raspberry either from the Terminal or through VSCode that lets one open a Workspace in VSCode over the SSH connection. While I’m actively working on an app, the latter is my preferred method. This way I can for example my Flask app the same way I would if I were working on my laptop directly.

For the purposes of what we’ll discuss here, we’ll use nginx to act as our reverse proxy (the main alternative here is Apache). For our HTTP server we’ll use gunicorn for Flask apps (or other Python-based apps) and shiny-server to run Shiny apps. We’ll also use ufw (Uncomplicated Firewall) to add some layer of security. When setting up ufw on a headless Raspberry Pi it’s important to allow SSH connections before enabling the firewall. Otherwise you will lose SSH access. Any other tools we will go through as they become relevant.

Step 2 - Building the apps

I haven’t gotten into the Node.js or React frameworks yet, but it’s on the list

For the purposes of this post I’ll show the setup for both Flask and Shiny apps since those are the ones I have most experience with. I for example have this Shiny app (code available on GitHub) that is currently hosted on shinyapps.io. This app calculates the cost for a trip with a car from the Norwegian car sharing service Bilkollektivet. This is the Shiny app we’ll work with today. I’ll show the Flask app later since that one isn’t hosted anywhere else.

I have a separate directory on my Raspberry called “projects/” where I collect all projects I’m working on.

Step 3 - Running the apps

For the Flask app I’ll have gunicorn installed in my virtual environment that runs a Python script called wsgi.py. This both runs the app and handles the headers for the proxy server we’ll define later. The entire contents of this script are:

# <app root path>/wsgi.py

from werkzeug.middleware.proxy_fix import ProxyFix
from app import app

if __name__ == "__main__":
    app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
    app.run()

Then gunicorn runs this script on a specified host. In order to get it to run I use a file in the /etc/systemd/system/ directory that you’ll notice contains a bunch of files with the .service extension. You can create your own to act as the local server. This step and the activation of this service on bootup I took from this tutorial.

# /etc/systemd/system/<app name>.service

[Unit]
Description=Gunicorn instance to serve the app
After=network.target

[Service]
User=<user>
Group=www-data
WorkingDirectory=<app root path>
Environment="PATH=<app root path>/.venv/bin"
ExecStart=<app root path>/.venv/bin/gunicorn --workers 3 --bind 0.0.0.0:<local port> --reload -m 007 wsgi:app

[Install]
WantedBy=multi-user.target
In the future I’d prefer to use Git here to reduce uptime in case I want to make bigger changes.

As for Shiny apps, When I’m ready to deploy the apps, I’ll create a symbolic link from the development directory to the srv/ directory where also the shiny-server server lives. This way any updates I make to the app is immediately visible online. The configuration for shiny-server is available in /etc/shiny-server/shiny-server.conf and looks something like this by default:

# /etc/shiny-server/shiny-server.conf

# Instruct Shiny Server to run applications as the user "shiny"
run_as shiny;

# Define a server that listens on port <port>
server {
  listen <port> 0.0.0.0;

  # Define a location at the base URL
  location / {

    # Host the directory of Shiny Apps stored in this directory
    site_dir /srv/shiny-server;

    # Log all Shiny output to files in this directory
    log_dir /var/log/shiny-server;

    # When a user visits the base URL rather than a particular application,
    # an index of the applications available in this directory will be shown.
    directory_index on;
  }
}

This means that the shiny-server is available on the specified port, and each app is located on the same port with a suffix to the url like localhost:<port>/<app-name>. This path is available on the local network (if the port is added to the list of exceptions in UFW).

Step 4 - Adding the reverse proxy

So now the apps are available in my local network on the ports in the configuration, but I would like an additional level of security and stability to my apps since I’ll be exposing them to the internet. For that I use a reverse proxy. There are two main reverse proxy servers, Apache and nginx. While Apache is (in my understanding) mostly used for large projects that demand a large body of functionality, nginx is a bit more lightweight and easier to start with, so I am using nginx here.

The config file for nginx itself is located in /etc/nginx/nginx.conf, but I haven’t touched that one much. Once nginx is running (you can check with sudo systemctl status nginx), the most important part is to define the config file for each app. Each app requires their own settings, but Google is a great help here.

The config files are located in /etc/nginx/sites-available/. There should already be one file called default located there. It’s possible to copy that one, but it’s probably just easier to create a new one. For Flask the config file needs the port under the proxy_pass setting where the gunicorn instance is running that I defined earlier and a the proxy port (under listen).

# /etc/nginx/sites-available/<app name>

server {
  listen <port>;

  server_name _;

  location / {
    proxy_pass http://127.0.0.1:<gunicorn port>/;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Prefix /;
  }
}

For Shiny apps served using shiny-server, the config file might look like this:

# /etc/nginx/sites-available/<app name>

server {
  listen <port>;

  server_name $hostname;

  proxy_read_timeout 300;
  proxy_connect_timeout 300;
  proxy_send_timeout 300;

  location / {
    proxy_pass http://127.0.0.1:<shiny-server port>/<app name>/;
    proxy_redirect http://127.0.0.1:<shiny-server port>/<app name>/ $scheme://$host/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
  }
}

You’ll notice the second one contains a few extra timeout settings. This is mainly because my Shiny apps are a bit heavier and might need some extra time to load and refresh. The other settings I included because I found them on the internet somewhere and they worked 🙂.

Sometimes you might also need to restart the systemd (not a typo) manager with sudo systemctl daemon-reload.

To enable nginx, the config file needs to be available also in the sites-enabled/ direcotry. The easiest way to make this happen is to create a symbolic link between the sites-available/ and the sites-enabled/ directory. This is easily done with ln -s /etc/nginx/sites-available/<app name> /etc/nginx/sites-enabled/. To test whether there are any issues in the config files one can run sudo nginx -t. To ensure nginx recognizes the new apps, nginx needs to be restarted using sudo service nginx restart.

If everything went well, the apps should be available now both on the specified ports on the machine and across the local network on <public IP>:<nginx port>. You’ll notice that for the Shiny app the address served changed from localhost:<shiny-server port>/<app name> to <hostname>:<nginx port>, removing the need for the suffix which makes it a fair bit easier to work with.

Step 5 - Putting everything online

This video was very useful here. The guy is not everybody’s cup of tea, but he seems to know what’s up

Now that the apps are available locally, the next step was tp allow machines outside my local network to access them too. There are a couple of ways to do this, but the most secure option seemed to be using a Zero Trust security model. This is implemented through a Cloudflare tunnel. This ensures that my public IP address can remain hidden and routes all traffic through the Cloudflare servers that will manage any requests and security protocols (see here for more info).

Just for the sake of it, I thought I’d try to create this landing page with Astro

My preferred way of establishing this tunnel is through a Docker image maintained by Cloudflare. So here are the steps. I bought a domain name from Cloudflare directly. It was actually quite cheap (~120 NOK a year) considering I didn’t need any hosting service. I could have bought a domain name elsewhere, but this seemed to be less of a hassle since I didn’t need to reroute any DNS name servers. I create a tunnel in the Cloudflare dashboard and run the provided Docker container on my Raspberry with the -d flag to run it in detached mode, ensure the containers run on reboot using the --restart unless-stopped flag, and give them a name so I can keep the Docker containers apart using the --name <app name> function. This way I can ensure that the apps are available online whenever my Raspberry is turned on. Then I specify the nginx port associated with each app in the Cloudflare dashboard associated with each app and specify the subdomain for each app like <flask app name>.danielroelfs.app or <shiny app name>.danielroelfs.app. The Cloudflare Zero Trust tunnel functionality is free and it offers a very easy and secure solution for a rather tricky issue.

Step 6 - Testing and maintenance

So now the apps are deployed. I used both a Flask app that tracks the books I’m reading and have read and a Shiny app to estimate car rental prices for a trip that I showed earlier as an example here, but I have a few more, like this app to visualize a few statistical principles I have had discussions with colleagues about. The most important last remaining step is to have a method to maintain the apps, keep the Docker containers up to date with the latest tags, and to test whether all apps function as they should across platforms, both desktop and mobile, different browsers, whether any database connections are functional and so on.

I’m very happy with my Raspberry Pi and could recommend anyone interested in full-stack developing to consider getting one. It’s definitely cheaper than hosting on AWS, and offers more flexibility tha many free tier hosting sites, though it’s definitely also fun (and quite reliable with the right tools) to connect a GitHub repository through GitHub Actions to Netlify and a separate SQL database server elsewhere, but having control of every step of the app is quite satisfying. Plus it has the added advantage of having an kill switch for all projects just by unplugging the Raspberry Pi. This post definitely helped me organize the steps I took to get this up and running so I hope it’s perhaps useful for others too!

References