Bundle a Node.js app within a rock

This tutorial describes the steps needed to bundle a typical Node.js application into a rock.

Setup your environment

We recommend starting from a clean Ubuntu installation. If we don’t have one available, we can create one using Multipass:

Is Multipass already installed and active? Check by running

snap services multipass

If we see the multipass service but it isn’t “active”, then we’ll need to run sudo snap start multipass. On the other hand, if we get an error saying snap "multipass" not found, then we must install Multipass:

sudo snap install multipass

Then we can create the VM with the following command:

multipass launch --disk 10G --name rock-dev 24.04

Finally, once the VM is up, open a shell into it:

multipass shell rock-dev

LXD will be required for building the rock. Make sure it is installed and initialised:

sudo snap install lxd
lxd init --auto

In order to create the rock, we’ll need to install Rockcraft:

sudo snap install rockcraft --classic

We’ll use Docker to run the rock. We can install it as a snap:

sudo snap install docker

By default, Docker is only accessible with root privileges (sudo). We want to be able to use Docker commands as a regular user:

sudo addgroup --system docker
sudo adduser $USER docker
newgrp docker
sudo snap disable docker
sudo snap enable docker

Warning

There is a known connectivity issue with LXD and Docker. If we see a networking issue such as “A network related operation failed in a context of no network access”, make sure to apply one of the suggested fixes here.

Note that we’ll also need a text editor. We can either install one of our choice or simply use one of the already existing editors in the Ubuntu environment (like vi).

Project setup

Starting in an empty folder, create a src/ subdirectory. Inside it, add two files:

The first one is the package.json listing of dependencies, with the following contents:

package.json
{
    "name": "node_web_app",
    "version": "1.0.0",
    "description": "Node.js on a rock",
    "author": "First Last <[email protected]>",
    "main": "server.js",
    "scripts": {
      "start": "node server.js"
    },
    "dependencies": {
      "express": "^4.18.2"
    }
}

The second file is our sample app, a simple “hello world” server. Still inside src/, add the following contents to server.js:

server.js
'use strict';

const express = require('express')
const app = express()
const port = 8080
const host = '0.0.0.0'

app.get('/', (req, res) => {
  res.send('Hello World from inside the rock!');
});

app.listen(port, host, () => {
  console.log(`Running on http://${host}:${port}`);
});

Next, we’ll setup the Rockcraft project. In the original empty folder, create an empty file called rockcraft.yaml. Then add the following snippets, one after the other:

Add the metadata that describes your rock, such as its name and licence:

rockcraft.yaml
name: my-node-app
base: [email protected]
version: '1.0'
summary: A rock that bundles a simple nodejs app
description: |
  This rock bundles a recent node runtime to serve a simple "hello-world" app.
license: GPL-3.0
platforms:
  amd64:

Add the container entrypoint, as a Pebble service:

rockcraft.yaml
services:
    app:
        override: replace
        command: node server.js
        startup: enabled
        on-success: shutdown
        on-failure: shutdown
        working-dir: /lib/node_modules/node_web_app

Finally, add a part that describes how to build the app created in the src/ directory using the npm plugin:

rockcraft.yaml
parts:
    app:
        plugin: npm
        npm-include-node: True
        npm-node-version: "21.1.0"
        source: src/

The whole file then looks like this:

rockcraft.yaml
name: my-node-app
base: [email protected]
version: '1.0'
summary: A rock that bundles a simple nodejs app
description: |
  This rock bundles a recent node runtime to serve a simple "hello-world" app.
license: GPL-3.0
platforms:
  amd64:

services:
    app:
        override: replace
        command: node server.js
        startup: enabled
        on-success: shutdown
        on-failure: shutdown
        working-dir: /lib/node_modules/node_web_app

parts:
    app:
        plugin: npm
        npm-include-node: True
        npm-node-version: "21.1.0"
        source: src/

Pack the rock with Rockcraft

To build the rock, run:

rockcraft pack

At the end of the process, a new rock file should be present in the current directory:

ls my-node-app_1.0_amd64.rock

Run the rock in Docker

First, import the recently created rock into Docker:

sudo rockcraft.skopeo --insecure-policy copy oci-archive:my-node-app_1.0_amd64.rock docker-daemon:my-node-app:1.0

Since the rock bundles a web-app, we’ll first start serving that app on local port 8000:

docker run --name my-node-app -p 8000:8080 my-node-app:1.0

The output will look similar to this, indicating that Pebble started the app service:

2023-10-30T12:37:33.654Z [pebble] Started daemon.
2023-10-30T12:37:33.659Z [pebble] POST /v1/services 3.878846ms 202
2023-10-30T12:37:33.659Z [pebble] Started default services with change 1.
2023-10-30T12:37:33.663Z [pebble] Service "app" starting: node server.js
2023-10-30T12:37:33.864Z [app] Running on http://0.0.0.0:8080

Next we’ll verify that the Node.js app is up and running. If you’re working on a regular Ubuntu system, open your web browser and go to http://localhost:8000. You should see a blank page with a “Hello World from inside the rock!” message. Success!

If, instead, you’re working in a Multipass VM, you can open another shell into the VM and access the app with curl:

multipass shell rock-dev
curl http://localhost:8000

This should also print “Hello World from inside the rock!” to the terminal.

You can now stop the running container by either interrupting it with Ctrl + C or by running the following in another terminal:

docker stop my-node-app

References

The sample app code comes from the “Hello world example” Express tutorial, available at https://expressjs.com/en/starter/hello-world.html.