Auto-install Node.js on a Raspberry Pi with a Debian package

For a recent project, I needed to deploy Node.js to a Raspberry Pi to run a small custom web service. This Pi was not intended to be a dedicated web service device. The web service was just needed for my project. Also, I wanted to be able to deploy to multiple Pis if needed. After considering my options, I landed on creating a Debian package (.deb) for my project that auto-installs my desired version of Node on installation of the package. This post details my solution.

tl;dr

In short I created a Debian package for my Node.js service, and wrote a post-install script that installed a standalone Node version when the Debian package was installed. I also included a systemd service file to run the service and to restart it in case it crashes. I published a sample project on Github. Feel free to grab it and use it as a model for your own Node.js services. If you want the gory details of how to do this, read on...

Why you shouldn't use this

First let me say that this is only one way to deploy Node on a Raspberry Pi (or any other Debian-based Linux for that matter). It works well when your need for Node is a part of a larger project. However, if you intend to run multiple Node services, it doesn't make sense for them to all use independent Node instances. In that case you would probably want to use something like nvm to manage your Node instances and then deploy your Node services like you would on a "normal" web server.

Overview

This process is best told using an example. You can find the example, which I called nodepi, on Github. Let's walk through the sample and I will explain each detail.

The Node service

For the Node service, I created the simplest Express-based service I could come up with. It listens on port 4200 and responds to any HTTP request with a simple string. That's all for the sample. If you are reading this article, I assume you know the Node part. In the root of the project, you will find package.json and the src directory. These are standard parts of a Node project. If you want to try out the sample, you should clone the repo and run npm install before moving on.

The Debian support files

All the Debian related files are under the deb directory. I intentionally did this so you could copy the deb directory to your own project and simply modify the contents to suit your needs. You will notice that the actual Debian information files are nested in deb/nodepi/debian. When building a Debian, the tools like to place things in the parent directory. Nesting them this deep will cause the output .deb package to be placed in the deb directory.

For any Debian package, you will need at least five files in the debian directory:

  • control - Contains information about the package itself
  • rules - Basically a makefile for the Debian packaging process
  • changelog - Contains a changelog of changes between versions of your program
  • copyright - Copyright information for your program
  • compat - The version of dkpg tools this package is compatible with

You will find all of these in the deb/nodepi/debian directory of the sample. Look them over and modify them to contain information about your project.

Additionally, the sample includes some optional Debian control files, postinst and postrm. These are shell scripts that run after the package install and after the package remove operations respectively. We will cover the postinst script in detail in a moment.

Also, in the sample, I include a couple of more files that are not Debian-package specific, but are still needed for my particular package. These are the nodepi.service and nodepi.sh files.

The starter script

The nodepi.sh script is a simple starter script for my Node service. It contains:

#!/bin/bash
#
# Starter script for nodepi
#
export PATH=/opt/nodepi/node/bin:$PATH
cd /opt/nodepi/src
node index.js

As you can see, it simply adds our custom Node installation to the PATH environment variable, changes directories to our service directory, and invokes our main JS file using Node.

The systemd service unit

The nodepi.service file is a systemd service unit. It contains:

[Unit]
Description=nodepi - example node installation for Raspberry Pi
After=network.target

[Service]
ExecStart=/opt/nodepi/nodepi.sh
StandardOutput=journal
User=pi
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

The Unit section tells systemd the textual description of this service and that it should not start until after the network is up.

The Service section calls our nodepi.sh starter script to start the service. It also tells systemd that we want the standard output of the node service to use journal which means the output will go to /var/log/syslog. (This is optional.) It also tells systemd to run our service as the pi user and that we want the service to be restarted, after a 10 second delay, if it fails.

Finally, the Install sections tells systemd to start our service when the Pi boots into multi-user mode.

The post-install script

The postinst script, the script that gets executed once dpkg is about to complete the installation of our .deb file, is where the magic happens. Here it is in it's entirety:

#!/bin/sh
# postinst script for nodepi
#
# Automatically downloads and installs node.js for the detected platform
#

set -e

DEB_ARCH=$(dpkg-architecture -q DEB_HOST_ARCH)
if [ "${DEB_ARCH}" = "armhf" ]; then
    ARCH=armv7l
else
    ARCH=x64
fi

NODE_VERSION=6.10.2
NODE_PKG=node-v${NODE_VERSION}-linux-${ARCH}.tar.xz
NODE_URL=https://nodejs.org/dist/v${NODE_VERSION}/${NODE_PKG}
SHA_URL=https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt
TMP_DIR=/opt/nodepi/tmp
NODE_DIR=/opt/nodepi/node

download_node()
{
    echo "Downloading node v${NODE_VERSION}..."
    wget -q -P $TMP_DIR ${NODE_URL}
}

do_configure()
{
    mkdir -p ${TMP_DIR}
    echo "Getting SHA sums..."
    rm -f ${TMP_DIR}/SHASUMS256.txt
    wget -q -P ${TMP_DIR} ${SHA_URL}
    CHECK_SHA=$(grep "linux-${ARCH}.*xz" ${TMP_DIR}/SHASUMS256.txt | awk '{print $1}')
    # Check to see if the package is already downloaded
    if [ -f ${TMP_DIR}/${NODE_PKG} ]; then
        NODE_SHA=$(sha256sum ${TMP_DIR}/${NODE_PKG} | awk '{print $1}')
        if [ "${NODE_SHA}" = "${CHECK_SHA}" ]; then
            echo "Node already downloaded..."
        else
            download_node
        fi
    else
        download_node
    fi

    echo "Checking package validity..."
    NODE_SHA=$(sha256sum ${TMP_DIR}/${NODE_PKG} | awk '{print $1}')
    if [ "${NODE_SHA}" != "${CHECK_SHA}" ]; then
        echo "Bad checksum: expected ${CHECK_SHA} got ${NODE_SHA}..."
        exit 1
    fi

    echo "Unpacking node v${NODE_VERSION}..."
    rm -rf ${NODE_DIR}
    mkdir -p ${NODE_DIR}
    tar -C ${NODE_DIR} --strip-components=1 -Jxf ${TMP_DIR}/${NODE_PKG}
}

case "$1" in
    configure)
        do_configure
    ;;

    abort-upgrade|abort-remove|abort-deconfigure)
    ;;

    *)
        echo "postinst called with unknown argument \`$1'" >&2
        exit 1
    ;;
esac

#DEBHELPER#

exit 0

It looks scary but it's not that bad! Let's start at the bottom:

case "$1" in
    configure)
        do_configure
    ;;

    abort-upgrade|abort-remove|abort-deconfigure)
    ;;

    *)
        echo "postinst called with unknown argument \`$1'" >&2
        exit 1
    ;;
esac

#DEBHELPER#

exit 0

This is standard boilerplate for a postinst script. It simply checks the supplied argument (which dpkg provides) and takes the appropriate action. I simply supplied the do_configure function call when configure is provided.

The #DEBHELPER# is a placeholder for the deb helper tools to poke in code when we build the package. Since our rules file called dh with --with=systemd, this placeholder will automatically get replaced with code to enable our systemd service unit and start it after installation.

Now back to the top:

DEB_ARCH=$(dpkg-architecture -q DEB_HOST_ARCH)
if [ "${DEB_ARCH}" = "armhf" ]; then
    ARCH=armv7l
else
    ARCH=x64
fi

NODE_VERSION=6.10.2
NODE_PKG=node-v${NODE_VERSION}-linux-${ARCH}.tar.xz
NODE_URL=https://nodejs.org/dist/v${NODE_VERSION}/${NODE_PKG}
SHA_URL=https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt
TMP_DIR=/opt/nodepi/tmp
NODE_DIR=/opt/nodepi/node

This block detects the architecture we are installing on. As of now, it only supports armhf (e.g. the Pi) and amd64. I wrote it this way so I could install this package on my Linux desktop and my Pi. Feel free to add other architectures if needed. Several other variables are then set based on the detected architecture.

We set NODE_VERSION to our desired version. We set NODE_PKG to the name of the binary package on the nodejs download site. For convenience we set NODE_URL to the full URL of the package we want. We set SHA_URL to the file that contains the SHA hashes of the packages so we can later check download integrity. Finally, we set TMP_DIR and NODE_DIR to our target installation directories that will contain the download files and the Node installation, respectively.

And now our functions:

download_node()
{
    echo "Downloading node v${NODE_VERSION}..."
    wget -q -P $TMP_DIR ${NODE_URL}
}

What can I say? It downloads Node to our temporary directory. It is called by our do_configure function:

do_configure()
{
    mkdir -p ${TMP_DIR}
    echo "Getting SHA sums..."
    rm -f ${TMP_DIR}/SHASUMS256.txt
    wget -q -P ${TMP_DIR} ${SHA_URL}
    CHECK_SHA=$(grep "linux-${ARCH}.*xz" ${TMP_DIR}/SHASUMS256.txt | awk '{print $1}')
    # Check to see if the package is already downloaded
    if [ -f ${TMP_DIR}/${NODE_PKG} ]; then
        NODE_SHA=$(sha256sum ${TMP_DIR}/${NODE_PKG} | awk '{print $1}')
        if [ "${NODE_SHA}" = "${CHECK_SHA}" ]; then
            echo "Node already downloaded..."
        else
            download_node
        fi
    else
        download_node
    fi

    echo "Checking package validity..."
    NODE_SHA=$(sha256sum ${TMP_DIR}/${NODE_PKG} | awk '{print $1}')
    if [ "${NODE_SHA}" != "${CHECK_SHA}" ]; then
        echo "Bad checksum: expected ${CHECK_SHA} got ${NODE_SHA}..."
        exit 1
    fi

    echo "Unpacking node v${NODE_VERSION}..."
    rm -rf ${NODE_DIR}
    mkdir -p ${NODE_DIR}
    tar -C ${NODE_DIR} --strip-components=1 -Jxf ${TMP_DIR}/${NODE_PKG}
}

The first part of the function downloads the SHA hash file and extracts the hash value for the version of Node we are downloading. The result is then stored in the CHECK_SHA variable. Next, the function checks to see if, by chance, we already have Node downloaded. If we do, and its SHA hash matches the check hash, we skip the download. If not, we go off and download Node.

Once the download is finished, we check the SHA hash of the downloaded file. If it does not match, package installation will fail. If it does, we unpack it to our Node directory and we are good to go.

The post-remove script

Here is postrm, the script that gets called if we are removing this package:

#!/bin/sh
# postrm script for nodepi

set -e

TMP_DIR=/opt/nodepi/tmp
NODE_DIR=/opt/nodepi/node

# Cleanup new directories created by the postinst script
cleanup()
{
    rm -rf ${TMP_DIR} ${NODE_DIR}
}

case "$1" in
    purge|remove|failed-upgrade|abort-install|abort-upgrade|disappear)
        cleanup
    ;;
    upgrade)
    ;;
    *)
        echo "postrm called with unknown argument \`$1'" >&2
        exit 1
    ;;
esac

#DEBHELPER#

exit 0

I think this one is pretty self-explanatory. Basically, if we are doing any operation other than an upgrade, we'll wipe out our download and Node directories. We don't do this on an upgrade, however. Why? If we are installing an update that just has modifications of our Node service, this will save us the download of Node since the version did not change.

Making the Debian package

In the deb/nodepi directory, you will find a Makefile. This file has two uses. First, we will use it to kickoff the Debian package build process, and second, debuild will use it to create the install image for our Node service. Let's look at it:

# Makefile for nodepi package
# 
# Creates a debian that will install the program in /opt
#
NAME = nodepi
VERSION ?= 1.0.0
DESTDIR ?= /
TAR_SRC = src node_modules/
TAR_DEB = Makefile
PKG_DIR = /opt/$(NAME)

.PHONY: default deb orig install

# There's no "build" step for JS projects, but debuild needs a default target
default:
    @echo "Ready for installation"

deb: orig
    debuild -us -uc -i -b --lintian-opts --profile debian

orig:
    tar zcf ../$(NAME)_$(VERSION).orig.tar.gz -C ../.. $(TAR_SRC)
    tar zcf ../$(NAME)_$(VERSION).orig.tar.gz $(TAR_DEB)

install:
    mkdir -p $(DESTDIR)/$(PKG_DIR)
    cp -r ../../node_modules $(DESTDIR)/$(PKG_DIR)
    cp -r ../../src $(DESTDIR)/$(PKG_DIR)
    cp debian/nodepi.sh $(DESTDIR)/$(PKG_DIR)
    mkdir -p $(DESTDIR)/lib/systemd/system
    cp debian/nodepi.service $(DESTDIR)/lib/systemd/system/

deb-clean:
    debuild clean
    rm -f ../$(NAME)_$(VERSION)*.build
    rm -f ../$(NAME)_$(VERSION)*.changes
    rm -f ../$(NAME)_$(VERSION)*.tar.gz

The interesting part for our purposes is the deb: target. This is what we will call to kick off the build process. You'll notice it first depends on the orig: target. The Debian packaging tools require a tar file with the "original" source in as part of their build process. So, the orig: target builds this tar file. It simply pulls in the src and node_modules directory from our "real" Node source. Next, it adds this Makefile since debuild will call the default: and install: targets from the "original" source.

Once the orig: target is satisfied, make calls:

debuild -us -uc -i -b --lintian-opts --profile debian

This builds the Debian package. The -us -uc options mean to build an unsigned package. The -i option tells it to ignore source control files (.git in this case). The -b means build just the binary package (e.g. no source package). Finally, --lintian-opts --profile debian means use Debian rules when checking the syntax of the package. (I needed this as a Mint user. Otherwise I would get warnings about Linux Mint rules.)

The final target, deb-clean: is just there for us to clean up artifacts if we desire.

Enough talk! Let's build the package! Depending on your build machine, you may need some pre-requisites installed. If needed, install those with:

sudo apt-get install build-essential debhelper devscripts

Then build the package with:

cd deb/nodepi
make deb

The package should come out in deb/ as nodepi_1.0.0_all.deb! At this point, you can copy the .deb file to your Pi and install it using:

sudo dpkg -i nodepi_1.0.0_all.deb

There are some caveats here. This is not an "official" Debian package that you can upload to a Debian repo somewhere. First, it's not signed. Second, as you probably saw during the build, lintian does not like that I put my files out in /opt, but that's where I really wanted them! However, the package will still install using dpkg -i on your Raspberry Pi.

Summary

In this post, we learned how to package up a Node.js service inside a Debian package that will install a sandboxed version of Node for us on installation of the package. We stepped through the details of each piece involved and learned how to build the package itself.

I hope you enjoyed this post and it helps you to deploy your own project. If you use this method and/or the sample in your project, I'd love to hear about it! Feel free to tweet at me, @elcarpie on Twitter, and tell me about your project!