Write your first Kubernetes charm for an Express app on Ubuntu 26.04

Imagine you have an Express app backed up by a database such as PostgreSQL and need to deploy it. In a traditional setup, this can be quite a challenge, but with Charmcraft you’ll find yourself packaging and deploying your Express app in no time. In this tutorial we will build a Kubernetes charm for an Express app using Charmcraft, so we can have an Express app up and running with Juju. Let’s get started!

This tutorial should take 90 minutes for you to complete.

If you’re new to the charming world, Express apps are specifically supported with a template to quickly generate a rock and a matching template to generate a charm. A rock is a special kind of OCI-compliant container image, while a charm is a software operator for cloud operations that use the Juju orchestration engine. The result is an Express app that can be easily deployed, configured, scaled, and integrated on any Kubernetes cluster.

What you’ll need

  • A local system, e.g., a laptop, with AMD64 or ARM64 architecture which has sufficient resources to launch a virtual machine with 4 CPUs, 8 GB RAM, and a 60 GB disk.

  • Familiarity with Linux.

The RAM and disk space are necessary to set up all the required software and to facilitate the creation of the rock and charm. If your local system has less than the sufficient resources, the tutorial will take longer to complete.

What you’ll do

  1. Create an Express app.

  2. Use that to create a rock with Rockcraft.

  3. Use that to create a charm with Charmcraft.

  4. Use that to test, deploy, configure, etc., your Express app on a local Kubernetes cloud with Juju.

  5. Repeat the process, mimicking a real development process.

Important

Should you get stuck or notice issues, please get in touch on Matrix or Discourse

Set things up

First, install Multipass. Launch an Ubuntu 26.04 VM named charm-dev:

multipass launch --cpus 4 --disk 60G --memory 8G --name charm-dev 26.04
multipass shell charm-dev

Unless stated otherwise, work entirely within the VM from now on.

Install Rockcraft, Charmcraft, and uv

For the Ubuntu 26.04 extensions, use the candidate release of Rockcraft and the edge release of Charmcraft. Install these tools and uv, the charm’s Python dependency manager, with classic confinement, which grants them access to the whole file system:

sudo snap install rockcraft --channel latest/candidate --classic
sudo snap install charmcraft --channel latest/edge --classic
sudo snap install astral-uv --classic

Install LXD, Canonical Kubernetes, and Juju

LXD provides the build environment for the rock and charm. Make sure it is installed:

lxd --version

If LXD is not installed, install it with sudo snap install lxd.

Initialize LXD:

lxd init --auto

Canonical Kubernetes will host the Express app and PostgreSQL. Install the Kubernetes and Juju snaps:

sudo snap install k8s --channel 1.34-classic/stable --classic
sudo snap install juju --channel 3.6/stable

Bootstrap a single-node cluster and enable local storage and Gateway API:

sudo k8s bootstrap --timeout 10m
sudo k8s status --wait-ready --timeout 10m
sudo k8s enable local-storage
sudo k8s enable gateway

The --wait-ready option waits for the cluster to become ready. Local storage supplies the volumes needed by Juju, PostgreSQL, and the image registry. Gateway API supplies the ingress controller used later in the tutorial.

Configure the load balancer to use the VM’s address, so the app will be accessible from outside the VM. Enable the load balancer:

sudo k8s enable load-balancer
sudo k8s kubectl wait --for=condition=Accepted gatewayclass/ck-gateway --timeout=5m

Save the node’s internal address in an environment variable and use it to set the address range for the load balancer:

export VM_IP=$(sudo k8s kubectl get nodes \
  -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
sudo k8s set load-balancer.cidrs="${VM_IP}/32"
printf 'VM address: %s\n' "$VM_IP"

Keep the printed VM address for any browser checks on your host machine. Later, the gateway action will return the full deployed app URL for browser and curl checks. The VM_IP variable is local to this VM terminal; it is not available in terminals on your host machine.

Create a local image registry

Canonical Kubernetes doesn’t include a registry addon. We’ll deploy an OCI registry in the cluster to store the rocks built during this tutorial. Download the registry manifest from the paas-charm repository:

curl --fail --location --output registry.yaml \
  https://raw.githubusercontent.com/canonical/paas-charm/main/docs/reuse/tutorial/registry_26_04.yaml

Apply the downloaded registry.yaml and wait for the registry to be ready:

sudo k8s kubectl apply -f registry.yaml
sudo k8s kubectl rollout status deployment/registry \
  -n charm-tutorial-registry --timeout=5m
curl --fail --retry 30 --retry-connrefused --retry-delay 2 "http://localhost:32000/v2/"

You can access the registry at localhost:32000 from within the VM. It uses a persistent volume so its images survive pod restarts. The NodePort service also exposes port 32000 on the VM’s network interfaces.

Bootstrap Juju

Register Canonical Kubernetes as a Juju cloud, then bootstrap the development controller:

mkdir -p ~/.local/share
sudo k8s config | juju add-k8s k8s --client
juju bootstrap k8s dev-controller --config bootstrap-timeout=600

It can take a few minutes to download the controller images.

The Ubuntu 26.04 extensions are experimental. Enable them for both tools in the terminal where you’ll build the rock and charm:

export ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=1
export CHARMCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=1

Create a directory for the app:

mkdir expressjs-hello-world
cd expressjs-hello-world

Create the Express app

Start by creating the “Hello, world” Express app that will be used for this tutorial.

Install npm and express-generator to initialize the Express module:

sudo apt update -y && sudo apt install npm -y
sudo npm install -g express-generator@4
express app
cd app

Run the Express app locally

Replace routes/index.js with the following code to return the initial greeting instead of the generated welcome page:

~/expressjs-hello-world/app/routes/index.js
var express = require('express');
var router = express.Router();

router.get('/', function(req, res, next) {
  res.type("text/plain").send("Hello world!");
});

module.exports = router;

Install the necessary packages for the app so it can run:

npm install

Note

During the npm install step, npm performs a security scan of downloaded dependencies, and it warns about any known vulnerabilities in installed packages. This warning is expected for this tutorial and should not affect the remaining steps. For production use, review and address dependency vulnerabilities before deployment.

With the packages installed, let’s run the Express app to verify that it works:

npm start

Verify the app

Test the Express app by using curl to send a request to the root endpoint. You will need a new terminal for this; use multipass shell charm-dev to open a new terminal in Multipass:

curl localhost:3000

The app should respond with Hello world!.

Close the app

The Express app looks good, so we can stop it for now from the original terminal using Ctrl + C.

Pack the Express app into a rock

Now let’s create a container image for our Express app. We’ll use a rock, which is an OCI-compliant container image based on Ubuntu.

First, we’ll need a rockcraft.yaml project file. We’ll take advantage of a pre-defined extension in Rockcraft with the --profile flag that caters initial rock files for specific web app frameworks. Using the expressjs-framework profile, Rockcraft automates the creation of rockcraft.yaml and tailors the file for an Express app. From the ~/expressjs-hello-world directory, initialize the rock:

cd ..
rockcraft init --profile expressjs-framework --base ubuntu@26.04

The rockcraft.yaml file will automatically be created and set the name based on your working directory.

Let’s verify that the project file is compatible with your host machine. Check the architecture of your system:

dpkg --print-architecture

Check out the contents of rockcraft.yaml:

cat rockcraft.yaml

The top of the file should look similar to the following snippet:

~/expressjs-hello-world/rockcraft.yaml
# See https://documentation.ubuntu.com/rockcraft/1/reference/extensions/express-framework
# For questions or help, visit https://matrix.to/#/#12-factor-charms:ubuntu.com

name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/1/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, an ubuntu base can be used
build-base: ubuntu@26.04 # build-base is required when the base is bare
version: '0.1' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS application # 79 char long summary
description: |
    This is expressjs-hello-world's description. You have a paragraph or two to tell the
    most important story about it. Keep it under 100 words though,
    we live in tweetspace and your description wants to look good in the
    container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
    amd64:
    # arm64:
    # ppc64el:
    # s390x:

...

Verify that the name is expressjs-hello-world.

Ensure that platforms includes the architecture of your host. Edit the platforms key in rockcraft.yaml if required.

Let’s pack the rock:

rockcraft pack

Depending on your system and network, this step can take several minutes to finish.

Once Rockcraft has finished packing the Express rock, the terminal will respond with something similar to Packed expressjs-hello-world_0.1_<architecture>.rock. The file name reflects your system’s architecture. After the initial pack, subsequent rock packings are faster.

The rock needs to be copied to the local OCI registry, which stores OCI archives so they can be downloaded and deployed in the Kubernetes cluster. Copy the rock:

rockcraft.skopeo copy \
  --insecure-policy \
  --dest-tls-verify=false \
  oci-archive:expressjs-hello-world_0.1_$(dpkg --print-architecture).rock \
  docker://localhost:32000/expressjs-hello-world:0.1

This command contains the following pieces:

  • --insecure-policy: adopts a permissive policy that removes the need for a dedicated policy file.

  • --dest-tls-verify=false: disables the need for HTTPS and verify certificates while interacting with the local OCI registry.

  • oci-archive: specifies the rock we created for our Express app.

  • docker: specifies the name of the image in the local OCI registry.

Create the charm

From the ~/expressjs-hello-world directory, let’s create a new directory for the charm and go inside it:

mkdir charm
cd charm

Similar to the rock, we’ll take advantage of a pre-defined extension in Charmcraft with the --profile flag that caters initial charm files for specific web app frameworks. Using the expressjs-framework profile, Charmcraft automates the creation of the files needed for our charm, including a charmcraft.yaml project file, pyproject.toml and source code for the charm. The source code contains the logic required to operate the Express app.

Initialize a charm named expressjs-hello-world:

charmcraft init --profile expressjs-framework --name expressjs-hello-world --base ubuntu@26.04

The files will automatically be created in your working directory.

The Ubuntu 26.04 profile uses the uv plugin and pyproject.toml for the charm dependencies. These dependencies are separate from those of your web app. Use uv, installed during setup, to create the lockfile before packing:

uv lock

The charm needs to know which port the app listens on. paas-config.yaml contains settings that the charm uses to run your app. Charmcraft packages this file with the charm, and the charm reads it when configuring the workload. Unlike options set with juju config, these settings are part of the packaged charm; changing them requires repacking and refreshing it.

Create paas-config.yaml beside charmcraft.yaml with the following content to set the app’s runtime port:

~/expressjs-hello-world/charm/paas-config.yaml
port: 8080

The port key replaces the Juju app-port option.

Check out the contents of charmcraft.yaml:

cat charmcraft.yaml

The top of the file should look similar to the following snippet:

~/expressjs-hello-world/charm/charmcraft.yaml
# This file configures Charmcraft.
# See https://documentation.ubuntu.com/charmcraft/stable/reference/files/charmcraft-yaml-file/ for guidance.
# For questions or help, visit https://matrix.to/#/#12-factor-charms:ubuntu.com

name: expressjs-hello-world

type: charm

base: ubuntu@26.04
charm-user: non-root

# the platforms this charm should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
  amd64:
  # arm64:
  # ppc64el:
  # s390x:

# (Required)
summary: A very short one-line summary of the ExpressJS application.

# (Required)
description: |
  A comprehensive overview of your ExpressJS application.

...

Verify that the name is expressjs-hello-world. Ensure that platforms includes the architecture of your host. Edit the platforms key in the project file if required.

Let’s pack the charm:

charmcraft pack

Depending on your system and network, this step can take several minutes to finish.

Once Charmcraft has finished packing the charm, the terminal will respond with something similar to Packed expressjs-hello-world_<architecture>.charm. The file name reflects your system’s architecture. After the initial pack, subsequent charm packings are faster.

Deploy the Express app

So far, we’ve packed our Express app into a rock and used that rock to create our corresponding charm. Now we have all the materials necessary to deploy the Express app with Juju.

A Juju model is needed to handle Kubernetes resources while deploying the Express app. The Juju model holds the app along with any supporting components. In this tutorial, our model will hold the Express app, ingress, and a PostgreSQL database.

Let’s create a new model:

juju add-model expressjs-hello-world

Constrain the Juju model to your architecture:

juju set-model-constraints -m expressjs-hello-world \
  arch=$(dpkg --print-architecture)

Now let’s use the OCI image we previously uploaded to deploy the Express app. Deploy using Juju by specifying the OCI image name with the --resource option:

juju deploy \
  ./expressjs-hello-world_$(dpkg --print-architecture).charm \
  expressjs-hello-world \
  --resource app-image=localhost:32000/expressjs-hello-world:0.1

It will take a few minutes to deploy the Express app. You can run juju status to monitor its progress:

juju status --watch 2s

It can take a couple of minutes for the app to finish the deployment. Once the status of the App has gone to active, you can stop watching using Ctrl + C. To continue monitoring your deployment, keep a juju status session active in a second terminal.

The Express app should now be running. We can monitor the status of the deployment using juju status, which should be similar to the following output:

ubuntu@charm-dev:~/expressjs-hello-world/charm$
juju status
Model                  Controller      Cloud/Region        Version  SLA          Timestamp
expressjs-hello-world  dev-controller  k8s  3.6.5    unsupported  12:24:51+03:00

App                    Version  Status  Scale  Charm                  Channel  Rev  Address        Exposed  Message
expressjs-hello-world           active      1  expressjs-hello-world             0  10.152.183.38  no

Unit                      Workload  Agent  Address      Ports  Message
expressjs-hello-world/0*  active    idle   10.1.157.75

Let’s expose the app using ingress so that we can access it. Deploy the gateway-api-integrator charm and integrate it with the Express app:

juju deploy gateway-api-integrator --channel=1/edge --trust
juju integrate gateway-api-integrator expressjs-hello-world

Configure the Gateway API integrator to use Canonical Kubernetes’ ck-gateway class and serve HTTP on its load-balancer IP address:

juju config gateway-api-integrator \
  gateway-class=ck-gateway enforce-https=false

We’ll leave external-hostname unset, and we won’t add a certificates relation for this isolated development VM. Use a domain and TLS in production.

The port in paas-config.yaml is 8080. The charm passes it to the app as PORT.

Monitor juju status until everything has a status of active. In the VM terminal, ask the gateway for the published endpoints:

juju run gateway-api-integrator/leader get-proxied-endpoints

The proxied-endpoints result contains a JSON object, for example:

proxied-endpoints: '{"gateway-api-integrator": {"url": "http://10.115.29.100"}, "expressjs-hello-world": {"url": "http://10.115.29.100/expressjs-hello-world-expressjs-hello-world"}}'

Copy the complete url for your application, not the gateway-api-integrator entry. The example address will differ on your VM. The app URL already includes its routing prefix; the gateway strips this prefix before forwarding requests to the app.

In the VM terminal, save that URL for the following commands, replacing <app-url> with the value returned by the action:

export APP_URL='<app-url>'

Use the same complete URL directly in a browser on your host machine. No DNS entry or custom Host header is needed. APP_URL exists only in the VM terminal where you exported it; repeat the export with the same returned URL if you open another VM terminal. If the gateway address changes, run the action again and update APP_URL.

Open the returned app URL in your host’s browser or send a request in the VM terminal:

curl "$APP_URL"

It should return Hello world!.

The development cycle

So far, we have worked though the entire cycle, from creating an app to deploying it. But now – as in every real-world case – we will go through the experience of iterating to develop the app, and deploy each iteration.

Configure the Express app

To demonstrate how to provide a configuration to the Express app, we will make the greeting configurable. The app will read this configuration from the GREETING_VAR environment variable. Go to the ~/expressjs-hello-world/app/routes directory and replace the code in index.js with the following:

~/expressjs-hello-world/app/routes/index.js
var express = require('express');
var router = express.Router();

let greeting = process.env["GREETING_VAR"]

if (!greeting){
  greeting = "Hello, world!";
}

/* GET home page. */
router.get('/', function(req, res, next) {
  res.type("text/plain").send(greeting);
});

module.exports = router;

Update the rock

Since we’re changing the app we should update the version of the rock. Return to the rock project directory:

cd ~/expressjs-hello-world

Increment the version in rockcraft.yaml to 0.2 such that the top of the rockcraft.yaml file looks similar to the following:

~/expressjs-hello-world/rockcraft.yaml
# See https://documentation.ubuntu.com/rockcraft/1/reference/extensions/express-framework
# For questions or help, visit https://matrix.to/#/#12-factor-charms:ubuntu.com

name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/1/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, an ubuntu base can be used
build-base: ubuntu@26.04 # build-base is required when the base is bare
version: '0.2' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS application # 79 char long summary
description: |
    This is expressjs-hello-world's description. You have a paragraph or two to tell the
    most important story about it. Keep it under 100 words though,
    we live in tweetspace and your description wants to look good in the
    container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
    amd64:
    # arm64:
    # ppc64el:
    # s390x:

...

Let’s pack and upload the new version of the rock:

rockcraft pack
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
  oci-archive:expressjs-hello-world_0.2_$(dpkg --print-architecture).rock \
  docker://localhost:32000/expressjs-hello-world:0.2

Redeploy the charm

We’ll redeploy the new version with juju refresh.

Return to the charm directory.

The expressjs-framework Charmcraft extension supports adding configurations to charmcraft.yaml, which will be passed as environment variables to the Express app. Add the following to the end of the charmcraft.yaml file:

~/expressjs-hello-world/charm/charmcraft.yaml
# configuration snippet for ExpressJS app

config:
  options:
    greeting:
      description: |
        The greeting to be returned by the Express application.
      default: "Hello, world!"
      type: string

Replace paas-config.yaml with the following content to map the new greeting option to GREETING_VAR, matching the app code:

~/expressjs-hello-world/charm/paas-config.yaml
port: 8080
config:
  options:
    greeting:
      env-var: GREETING_VAR

The option is declared in charmcraft.yaml. By default, greeting would be exposed as APP_GREETING. Setting env-var overrides that name with GREETING_VAR, without adding a framework prefix or changing the option’s value. Changing the packaged mapping requires repacking the charm; changing the greeting value through Juju does not require rebuilding the rock. The paas-config.yaml reference describes the other packaged settings.

We can now pack and deploy the new version of the Express app:

charmcraft pack
juju refresh expressjs-hello-world \
  --path=./expressjs-hello-world_$(dpkg --print-architecture).charm \
  --resource app-image=localhost:32000/expressjs-hello-world:0.2

Monitor juju status until the app goes back to active again. Verify that the new configuration has been added:

juju config expressjs-hello-world | grep -A 6 greeting:

Check that the response is now Hello, world! using:

curl "$APP_URL"

Now let’s change the greeting:

juju config expressjs-hello-world greeting='Hi!'

Wait for the app to restart, then check the response:

curl "$APP_URL"

The response should now return the updated Hi! greeting.

Integrate with a database

Now let’s keep track of how many visitors your app has received. This will require integration with a database to keep the visitor count. This will require a few changes:

  • We will need to create a database migration that creates the visitors table.

  • We will need to keep track how many times the root endpoint has been called in the database.

  • We will need to add a new endpoint to retrieve the number of visitors from the database.

Let’s start with the database migration to create the required tables. The charm created by the expressjs-framework extension will execute the migrate.sh script if it exists. This script should ensure that the database is initialized and ready to be used by the app. We will create a migrate.sh file containing this logic.

Back out to the ~/expressjs-hello-world/app directory. Create the migrate.sh file using a text editor and paste the following code into it:

~/expressjs-hello-world/app/migrate.sh
#!/bin/bash

PGPASSWORD="${POSTGRESQL_DB_PASSWORD}" psql -h "${POSTGRESQL_DB_HOSTNAME}" -U "${POSTGRESQL_DB_USERNAME}" "${POSTGRESQL_DB_NAME}" -c "CREATE TABLE IF NOT EXISTS visitors (timestamp TIMESTAMP NOT NULL, user_agent TEXT NOT NULL);"

Note

The charm will pass the Database connection string in the POSTGRESQL_DB_CONNECT_STRING environment variable once PostgreSQL has been integrated with the charm.

Change the permissions of the file migrate.sh so that it is executable:

chmod u+x migrate.sh

Regarding the migrate.sh file describes how to use the migrate.sh file and run CLI tools for database migration in production. Alternatively, use different tooling for migration, for example prisma or knex .

To connect the Express app to PostgreSQL, we will use the pg-promise library. The app code needs to be updated to keep track of the number of visitors and to include a new endpoint to retrieve the number of visitors. Create a new file called visitors.js in the ~/expressjs-hello-world/app/routes directory and paste the following code into it:

~/expressjs-hello-world/app/routes/visitors.js
var express = require("express");
var router = express.Router();
const pgp = require("pg-promise")(/* options */);
const PG_CONNECT_STR = process.env["POSTGRESQL_DB_CONNECT_STRING"];

console.log("PG_CONNECT_STR", PG_CONNECT_STR);
let db = null;

if (PG_CONNECT_STR) {
  db = pgp(PG_CONNECT_STR);
}
/* GET visitors count. */
router.get("/", async function (req, res, next) {
  console.log("visitors request");

  if (!db) {
    console.error("Database connection is not initialized");
    return res.status(500).type("text/plain").send("Database connection error");
  }

  try {
    const result = await db.one("SELECT count(*) FROM visitors");
    const numVisitors = result.count;

    res.type("text/plain").send(`Number of visitors ${numVisitors}`);
  } catch (error) {
    console.error("An error occurred while executing query:", error);
    res.status(500).type("text/plain").send("Error retrieving visitors count");
  }
});

module.exports = router;

Open index.js in a text editor and replace the code in it with the following to record the number of visitors:

~/expressjs-hello-world/app/routes/index.js
var express = require('express');
var router = express.Router();
const pgp = require("pg-promise")(/* options */);
const PG_CONNECT_STR = process.env["POSTGRESQL_DB_CONNECT_STRING"];

/* GET home page. */
router.get('/', async function(req, res, next) {
  console.log("new hello world request");

  if (!PG_CONNECT_STR) {
    console.error("Database connection string is not set");
    return res.status(500).type("text/plain").send("Database connection error");
  }

  const db = pgp(PG_CONNECT_STR);

  try {
    const userAgent = req.get("User-Agent");
    const timestamp = new Date();

    await db.none(
      "INSERT INTO visitors (timestamp, user_agent) VALUES ($1, $2)",
      [timestamp, userAgent]
    );

    const greeting = process.env["GREETING_VAR"] || "Hello, world!";
    res.type("text/plain").send(greeting);
  } catch (error) {
    console.error("An error occurred:", error);
    res.status(500).type("text/plain").send("An error occurred while processing your request");
  } finally {
    db.$pool.end(); // Close the database connection pool
  }
});

module.exports = router;

Return to the ~/expressjs-hello-world/app directory, open app.js in a text editor and replace its content with the following code to add the new route:

~/expressjs-hello-world/app/app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var visitorsRouter = require('./routes/visitors');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use('/visitors', visitorsRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

Add the new package in the Express project:

npm install pg-promise

Update the rock again

Return to the rock project directory:

cd ~/expressjs-hello-world

For the migrations to work, we need the postgresql-client package installed in the rock. By default, the expressjs-framework uses the bare base, so we will also need to install a shell interpreter. Let’s do it as a slice, so that the rock doesn’t include unnecessary files. Open the rockcraft.yaml file using a text editor and add the following to the end of the file:

~/expressjs-hello-world/rockcraft.yaml
parts:
  runtime-debs:
    plugin: nil
    stage-packages:
      # Added manually for the migrations
      - postgresql-client
  runtime-slices:
    plugin: nil
    stage-packages:
      # Added manually for the migrations
      - bash_bins

Increment the version in rockcraft.yaml to 0.3 such that the top of the rockcraft.yaml file looks similar to the following:

~/expressjs-hello-world/rockcraft.yaml
# See https://documentation.ubuntu.com/rockcraft/1/reference/extensions/express-framework
# For questions or help, visit https://matrix.to/#/#12-factor-charms:ubuntu.com

name: expressjs-hello-world
# see https://documentation.ubuntu.com/rockcraft/1/explanation/bases/
# for more information about bases and using 'bare' bases for chiselled rocks
base: bare # as an alternative, an ubuntu base can be used
build-base: ubuntu@26.04 # build-base is required when the base is bare
version: '0.3' # just for humans. Semantic versioning is recommended
summary: A summary of your ExpressJS application # 79 char long summary
description: |
    This is expressjs-hello-world's description. You have a paragraph or two to tell the
    most important story about it. Keep it under 100 words though,
    we live in tweetspace and your description wants to look good in the
    container registries out there.
# the platforms this rock should be built on and run on.
# you can check your architecture with `dpkg --print-architecture`
platforms:
    amd64:
    # arm64:
    # ppc64el:
    # s390x:

...

Let’s pack and upload the new version of the rock:

rockcraft pack
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
  oci-archive:expressjs-hello-world_0.3_$(dpkg --print-architecture).rock \
  docker://localhost:32000/expressjs-hello-world:0.3

Update the charm again

Return to the charm directory.

The Express app now requires a database which needs to be declared in the charmcraft.yaml file. Open charmcraft.yaml in a text editor and add the following section to the end of the file:

~/expressjs-hello-world/charm/charmcraft.yaml
# requires snippet for Express app with a database

requires:
  postgresql:
    interface: postgresql_client
    optional: false

We can now pack and deploy the new version of the Express app:

charmcraft pack
juju refresh expressjs-hello-world \
  --path=./expressjs-hello-world_$(dpkg --print-architecture).charm \
  --resource app-image=localhost:32000/expressjs-hello-world:0.3

Now let’s deploy PostgreSQL and integrate it with the Express app:

juju deploy postgresql-k8s --trust --channel 14/stable
juju integrate expressjs-hello-world postgresql-k8s

Wait for juju status to show that the App is active again. During this time, the Express app may enter a blocked state as it waits to become integrated with the PostgreSQL database. Due to the optional: false key in the endpoint definition, the Express app will not start until the database is ready.

Once the Express app is active, send a request to the endpoint:

curl "$APP_URL"

It should still return the Hi! greeting.

Check the local visitors:

curl "${APP_URL%/}/visitors"

This request should return Number of visitors 1 after the previous request to the root endpoint. This should be incremented each time the root endpoint is requested. If we repeat this process, the output should be as follows:

ubuntu@charm-dev:~/expressjs-hello-world/charm$
curl "$APP_URL"
Hi!
ubuntu@charm-dev:~/expressjs-hello-world/charm$
curl "${APP_URL%/}/visitors"
Number of visitors 2

Tear things down

If you’d like to quickly tear things down, start by exiting the Multipass VM:

exit

And then you can proceed with its deletion:

multipass delete charm-dev
multipass purge

If you’d like to manually reset your working environment, you can run the following in the project directory ~/expressjs-hello-world/charm for the tutorial:

charmcraft clean
# Back out to main directory for cleanup
cd ..
rockcraft clean
# exit and delete the charm dir
rm -rf charm
# delete app directory
rm -rf app
# delete all the files created during the tutorial
rm expressjs-hello-world_0.1_$(dpkg --print-architecture).rock \
  expressjs-hello-world_0.2_$(dpkg --print-architecture).rock \
  expressjs-hello-world_0.3_$(dpkg --print-architecture).rock \
  rockcraft.yaml
# Remove the juju model
juju destroy-model expressjs-hello-world --destroy-storage --no-prompt --force

You can also clean up your Multipass instance by exiting and deleting it using the same commands as above.

Conclusion and next steps

You’ve reached the end of this tutorial. You made it through the entire development process, including:

  • Creating a Express app

  • Deploying the app locally

  • Packaging the app using Rockcraft

  • Building the app with Ops code using Charmcraft

  • Deploying the app using Juju

  • Exposing the app using an ingress

  • Configuring the app

  • Integrating the app with a database

Please provide feedback about your experience with this tutorial by completing the following Google form: 26.04 tutorial feedback

By the end of this tutorial, you built a charm and evolved it in a number of typical ways. But there is a lot more to explore: