Deploy an Express app with Kamal

February 09, 2025

Kamal offers zero-downtime deploys, rolling restarts, asset bridging, remote builds, accessory service management, and everything else you need to deploy and manage your web app in production with Docker. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized.

As of Rails 8, the Kamal gem is included by default in new Rails apps. It has made deploying a Rails app to your own cloud instance with providers like DigitalOcean or Hetzner trivial—no more reliance on PaaS.

But that’s not what this post is about. Here, we’ll deploy a Node.js Express app with Kamal. Let’s get right into it.

Requirements


Express App


Let’s start with an Express boilerplate. Run the following commands:

mkdir node_kamal
cd node_kamal
yarn init -y (or npm init -y)
yarn add express (or npm install express)
touch index.js

Paste the following snippet in the index.js file.

// index.js

const express = require("express");
const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.get("/up", (req, res) => {
  res.send("App is up!");
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Kamal expects your app to have a public /up endpoint that returns a 200 status code. This is configurable, but for now we'll keep it simple and go with the defaults.

At this point, you should be able to run node index.js and see:

Example app listening on port 3000

That's all the Express we'll be touching on for this post. You can shut it down now.

Dockerfile


Next, create a Dockerfile

touch Dockerfile

and paste the following snippet:

# Use an official lightweight Node.js image
FROM node:20-alpine

# Set environment variables
ENV NODE_ENV=production

# Set the working directory inside the container
WORKDIR /app

# Copy package.json and yarn.lock
COPY package.json yarn.lock ./
# (or package-lock.json if you used NPM)
# COPY package.json package-lock.json ./

# Install app dependencies using Yarn
RUN yarn install --frozen-lockfile --production
RUN apk add --no-cache bash

# Copy the rest of the application code
COPY . .

# Expose the port your app runs on
EXPOSE 80

# Start the application
CMD ["node", "index.js"]

Kamal


If you don't already have kamal installed open your terminal and run:

gem install kamal

Make sure your $KAMAL_REGISTRY_PASSWORD variable is set. This variable represents your Docker personal access token.

You can generate one here:

https://app.docker.com/settings/personal-access-tokens

Next run:

kamal init

You should see new .kamal and config folders created in your node_kamal repository. There's two files here we care about.

Overwrite your config/deploy.yml file with the following snippet:

# Name of your application. Used to uniquely configure containers.
service: nameofyourapp # TODO: use your app name instead

# Name of the container image.
image: yourdockerusername/nameofyourapp # TODO: use your dockername/appname instead

# Deploy to these servers.
servers:
  web:
    - 1.234.56.789 # TODO: Use your server's IP address instead.

proxy:
  ssl: true
  host: yourdomain.com # TODO: use your domain instead

  healthcheck:
    path: /up
    interval: 2
    timeout: 2

# Credentials for your image host.
registry:
  username: yourdockerusername # TODO: use your Docker username instead

  # Always use an access token rather than real password (pulled from .kamal/secrets).
  password:
    - KAMAL_REGISTRY_PASSWORD

# Configure builder setup.
builder:
  arch: amd64
# Inject ENV variables into containers (secrets come from .kamal/secrets).
#
env:
  clear:
    PORT: 80
    NODE_ENV: production
  secret:
    # Here you can place secrets you don't want to commit to git
    # You will link them in the .kamal/secrets file
    # - JWT_SECRET TODO: just an example of how to use, not necessary for this app

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
aliases:
  shell: app exec --interactive --reuse "bash"

Now jump into the .kamal/secrets file and paste the following:

KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# JWT_SECRET= your super secret jwt password, TODO: just an example not necessary for this app

Kamal uses git to determine what to push to the cloud so make sure to run:

git add .
git commit -m "way too many changes for one commit"

If you haven’t done so already, create an A record for yourdomain.com pointing to 1.234.56.789 (but use your actual domain and server’s IP address instead).

Now we’re ready to deploy. The first time you deploy to this server, run:

kamal setup

After that, every time you want to push a change to production, just commit your changes and run:

kamal deploy

That’s it! You now have an Express app deployed with SSL on your own VPS—no PaaS required.

But this is just scratching the surface of what Kamal can do. Make sure to check out their docs to learn more.