cloud-devops

Deploy a Node.js App to a VPS with Kamal 2 (2026)

June 21, 2026

Deploy a Node.js App to a VPS with Kamal 2 (2026)

To deploy a Node.js app to a VPS with Kamal 2, containerize it with a Dockerfile, describe your server and image in config/deploy.yml, and run kamal setup. Kamal builds the image, pushes it to a registry, pulls it onto your server over SSH, and swaps traffic through kamal-proxy with zero downtime.

TL;DR

This hands-on guide takes a plain Express 5 API from an empty folder to a live, HTTPS-secured server using Kamal 2.12.01. You will write a small Dockerfile on the Node.js 24 LTS base image, scaffold Kamal config with kamal init, configure kamal-proxy for a non-Rails app on port 3000, store registry and database credentials in .kamal/secrets, run PostgreSQL 18 as a Kamal accessory, and ship your first zero-downtime deploy. Kamal is framework-agnostic — nothing here is Rails-specific. Budget about 45 minutes plus the cost of one small VPS.

What you'll learn

  • Build a minimal but real Express 5 API with a /up health endpoint and a Postgres-backed route
  • Write a small, production-ready Node.js Dockerfile on the Node 24 LTS image
  • Scaffold Kamal config with kamal init and understand the deploy.yml schema
  • Configure kamal-proxy for a non-Rails app (app_port: 3000, host, healthcheck)
  • Manage registry and database secrets safely with .kamal/secrets
  • Run PostgreSQL 18 as a Kamal accessory and connect to it over the kamal network
  • Ship your first zero-downtime deploy with kamal setup and understand how the swap works
  • Turn on automatic Let's Encrypt HTTPS with one config line
  • Verify the live deployment and fix the five most common first-deploy failures

Prerequisites

You'll need the following pinned versions and resources:

  • Ruby 3.2+ on your local machine (Kamal is distributed as a Ruby gem). Check with ruby -v.
  • Docker running locally (Docker Desktop 4.x or Docker Engine 27+) so Kamal can build your image. Check with docker version.
  • Node.js 24 locally if you want to run the app before deploying. Node 24 is the Active LTS line as of June 2026 (supported through April 2028)2.
  • A VPS running Ubuntu 24.04 with a public IP and root SSH-key access. Any provider works — DigitalOcean, Hetzner, Linode/Akamai, Vultr. A 1 GB / 1 vCPU instance is enough for this guide.
  • A domain name with an A record pointing at your server's IP. You need this for automatic SSL.
  • A Docker Hub account (free) to host your image. Any registry works; Docker Hub is the default.
  • Ports 80 and 443 open on the server's firewall. kamal-proxy listens on both, and port 443 must be reachable for the Let's Encrypt challenge to succeed3.

Throughout this guide, the example service is called notes-api. Replace notes.example.com with your real domain and 203.0.113.10 with your real server IP.

Step 1 — Build a minimal Express API with a health endpoint

Kamal deploys whatever your Dockerfile builds, so first we need a real app. Create a project folder and a small Express 5 API backed by Postgres. The one endpoint Kamal cares about is GET /up — kamal-proxy polls it to decide when a freshly started container is ready to receive traffic3.

mkdir notes-api && cd notes-api
npm init -y
npm install express@5.2.1 pg@8.21.0

Set the project to use ES modules and a start script. Edit package.json:

{
  "name": "notes-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": { "start": "node src/server.js" },
  "dependencies": {
    "express": "5.2.1",
    "pg": "8.21.0"
  }
}

Create src/db.js. It builds a pg connection pool from DATABASE_URL (which Kamal injects in production) and falls back to a local Postgres for development:

import pg from 'pg';

const { Pool } = pg;

export const pool = new Pool({
  connectionString:
    process.env.DATABASE_URL ??
    'postgres://notes:notes@localhost:5432/notes',
});

// Create the table on boot. For a tutorial this is fine;
// in a larger app use a real migration tool instead.
export async function initSchema() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS notes (
      id         SERIAL PRIMARY KEY,
      body       TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
  `);
}

// Postgres in a freshly booted accessory may need a moment to
// accept connections. Retry a few times before giving up.
export async function waitForDb(retries = 10, delayMs = 1500) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      await pool.query('SELECT 1');
      return;
    } catch (err) {
      if (attempt === retries) throw err;
      console.log(`db not ready (attempt ${attempt}), retrying...`);
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

Now create src/server.js with the routes and a health endpoint:

import express from 'express';
import { pool, initSchema, waitForDb } from './db.js';

const app = express();
app.use(express.json());

const PORT = Number(process.env.PORT ?? 3000);

// kamal-proxy hits GET /up to decide when this container is live.
app.get('/up', (_req, res) => res.status(200).send('OK'));

app.get('/', async (_req, res) => {
  const { rows } = await pool.query('SELECT count(*)::int AS count FROM notes');
  res.json({ message: 'notes-api is live', notes: rows[0].count });
});

app.get('/notes', async (_req, res) => {
  const { rows } = await pool.query(
    'SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 100',
  );
  res.json(rows);
});

app.post('/notes', async (req, res) => {
  const body = (req.body?.body ?? '').toString().trim();
  if (!body) return res.status(422).json({ error: 'body is required' });
  const { rows } = await pool.query(
    'INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at',
    [body],
  );
  res.status(201).json(rows[0]);
});

await waitForDb();
await initSchema();

const server = app.listen(PORT, () => {
  console.log(`notes-api listening on :${PORT}`);
});

// Finish in-flight requests when Kamal stops the old container.
for (const signal of ['SIGTERM', 'SIGINT']) {
  process.on(signal, () => {
    server.close(() => pool.end().then(() => process.exit(0)));
  });
}

Two design choices matter for deployment. First, the app listens on PORT (3000 by default) — we'll tell kamal-proxy about that port in Step 4. Second, the SIGTERM handler closes the HTTP server gracefully, which is what makes the traffic swap genuinely seamless: in-flight requests on the old container finish instead of being cut off.

Step 2 — Write a production Node.js Dockerfile

The Kamal 2 Node.js Dockerfile should be small, run as a non-root user, and install only production dependencies. Create Dockerfile in the project root on the node:24-slim base image (the Debian-slim variant of the Node 24 LTS line)2:

# syntax=docker/dockerfile:1
FROM node:24-slim

ENV NODE_ENV=production
WORKDIR /app

# Install production dependencies first for better layer caching.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Copy the application source.
COPY . .

# Run as the unprivileged "node" user that ships with the image.
USER node

EXPOSE 3000
CMD ["node", "src/server.js"]

Add a .dockerignore so you don't ship local cruft or secrets into the image:

node_modules
npm-debug.log
.git
.kamal
.env

Generate the package-lock.json that npm ci requires by running npm install once locally if you haven't already. Confirm the image builds before involving a server:

docker build -t notes-api:test .

A successful build ends with naming to docker.io/library/notes-api:test. If it fails here, fix it now — Kamal runs the exact same build, so a local failure is a deploy failure.

Step 3 — Install Kamal and run kamal init

Install Kamal globally from RubyGems4:

gem install kamal
kamal version

You should see 2.12.0 (or any 2.11+ release — the deploy.yml schema and commands in this guide are stable across the entire 2.x line)1. If you'd rather not install Ruby, Kamal also ships as a Docker image, though that path has some limitations4.

Scaffold the configuration. From your project root:

kamal init

This creates a config stub at config/deploy.yml and a secrets stub in the .kamal/ directory5. Kamal uses config/deploy.yml regardless of language — there is nothing Rails-specific about the path. We'll replace the generated deploy.yml wholesale in the next step.

Step 4 — Configure deploy.yml for a non-Rails app

This is the heart of the kamal deploy.yml config for a Node app. Open config/deploy.yml and replace it with the following, substituting your own service name, image repository, server IP, and domain:

# config/deploy.yml
service: notes-api

# Your image on Docker Hub: <username>/<repo>
image: your-dockerhub-user/notes-api

# The server(s) to deploy to.
servers:
  web:
    - 203.0.113.10

# kamal-proxy settings for THIS app (not Rails-specific).
proxy:
  host: notes.example.com
  app_port: 3000   # our Express app listens on 3000, not the default 80
  ssl: true        # automatic HTTPS via Let's Encrypt (see Step 8)

# Docker Hub is the default registry.
registry:
  username: your-dockerhub-user
  password:
    - KAMAL_REGISTRY_PASSWORD

# Build an amd64 image (most VPSes are x86-64).
builder:
  arch: amd64

# Environment passed to the app container.
env:
  clear:
    PORT: 3000
  secret:
    - DATABASE_URL

The four lines that trip up people coming from Rails tutorials are in the proxy: block:

  • app_port: 3000 — the port your container exposes. It defaults to 80, which is wrong for our Express app, so we set it explicitly3.
  • ssl: true — turns on automatic Let's Encrypt certificates. It requires a single server and a host value that resolves to that server3. We cover the DNS requirement in Step 8.
  • host: notes.example.com — kamal-proxy only routes requests for this hostname to your app, which is what lets multiple apps share one server.
  • builder.arch: amd64 — if you build on an Apple Silicon (arm64) Mac and deploy to an x86-64 server, you must cross-build for amd64 or the container won't start4.

Note there is no traefik: block. Older Kamal 1 tutorials configured Traefik with Docker labels; Kamal 2 replaced that entirely with the built-in kamal-proxy, configured under proxy:3. If a guide tells you to add traefik: labels, it's out of date.

Step 5 — Store secrets in .kamal/secrets

Your registry password and database URL must never live in deploy.yml or Git. Kamal loads secrets from .kamal/secrets using dotenv, and supports variable and command substitution6. Edit .kamal/secrets:

# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
DATABASE_URL=postgres://notes:$POSTGRES_PASSWORD@notes-api-db:5432/notes

This reads KAMAL_REGISTRY_PASSWORD and POSTGRES_PASSWORD from your shell environment, then composes a DATABASE_URL that reuses the same password. The database host is notes-api-db — that's the accessory container name we create in the next step (<service>-<accessory>).

Confirm .kamal/secrets is git-ignored (the kamal init scaffold adds it to .gitignore, but verify)6:

grep -q '.kamal/secrets' .gitignore && echo "ignored ✓" || echo ".kamal/secrets" >> .gitignore

Now export the real values into your shell before any deploy command. Use a Docker Hub access token (not your account password) for the registry, and generate a strong database password:

export KAMAL_REGISTRY_PASSWORD="dckr_pat_your_access_token"
export POSTGRES_PASSWORD="$(openssl rand -hex 24)"

Secrets listed under env: secret: are not baked into the image — Kamal writes them to an env file on the host and passes them to docker run at boot6, so rotating a password is just a redeploy.

Step 6 — Add a PostgreSQL 18 accessory

A Kamal accessory is a supporting container — a database, cache, or search engine — that Kamal boots and manages but does not redeploy on every app push7. Add a Postgres 18 accessory to config/deploy.yml:

accessories:
  db:
    image: postgres:18
    host: 203.0.113.10
    # Bind to localhost only so Postgres is never exposed publicly.
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_USER: notes
        POSTGRES_DB: notes
      secret:
        - POSTGRES_PASSWORD
    directories:
      - pgdata:/var/lib/postgresql

Three details are easy to get wrong:

  • postgres:18 changed its data directory. As of PostgreSQL 18 the image's declared volume moved to /var/lib/postgresql (and PGDATA is now version-specific at /var/lib/postgresql/18/docker). Mount your persistent volume at /var/lib/postgresql, not the old /var/lib/postgresql/data — the old path silently breaks persistence and can lose data on container recreation89.
  • port: "127.0.0.1:5432:5432" binds Postgres to loopback on the host, so it is reachable by your app over the private kamal network but never from the public internet7.
  • The accessory's container name is notes-api-db — the service name (notes-api) joined to the accessory key (db)7. That's exactly the host in the DATABASE_URL from Step 5.

How does the app reach notes-api-db? Kamal attaches both the app and its accessories to a user-defined Docker bridge network named kamal by default7. User-defined bridge networks provide automatic DNS resolution by container name, so postgres://notes:...@notes-api-db:5432/notes resolves without exposing any public port.

Step 7 — Point DNS at your server

Before the first deploy, make sure the world can reach your server on the hostname you configured:

  1. In your DNS provider, create an A record for notes.example.com pointing at 203.0.113.10.
  2. Confirm it has propagated:
dig +short notes.example.com
# should print 203.0.113.10
  1. Make sure the server firewall allows inbound 80 and 443. On a fresh Ubuntu box with ufw:
ssh root@203.0.113.10 'ufw allow 80 && ufw allow 443 && ufw status'

DNS must resolve to the server and port 443 must be open before you enable SSL, because Let's Encrypt validates your domain by connecting to kamal-proxy on that server3. You do not need to install Docker yourself — kamal setup does that for you over SSH4.

Step 8 — First deploy with kamal setup

Everything is in place. Run the first-time setup from your project root:

kamal setup

kamal setup sets up all accessories, pushes the environment, and deploys the app to your servers5. Concretely it connects over SSH as root, installs Docker if it's missing (via get.docker.com), logs into your registry locally and remotely, boots the Postgres accessory, builds your image from the Dockerfile, pushes it to Docker Hub, pulls it on the server, starts kamal-proxy on ports 80 and 443, starts your app container, and routes traffic to it once GET /up returns 200 OK4. Because accessories are set up before the app deploys, your database is already accepting connections by the time the Express container boots.

When it finishes, your app is live. Visit https://notes.example.com and you should see:

{ "message": "notes-api is live", "notes": 0 }

For every deploy after the first, you don't re-bootstrap the server — you just run:

kamal deploy

How Kamal achieves zero-downtime deploys

Kamal achieves zero downtime by never stopping the old container until the new one is proven healthy. kamal-proxy sits on ports 80/443 and forwards requests to your app container3. On each kamal deploy, Kamal starts a new app container alongside the running one, then polls GET /up (once per second, with a 5-second timeout per request) until the new container answers 200 OK3. Only then does the proxy switch traffic to the new container and stop the old one4. Combined with the SIGTERM handler from Step 1, in-flight requests on the old container drain cleanly, so users never see a dropped connection or a 502.

One caveat worth knowing: accessories like Postgres are not part of this zero-downtime swap. They are booted once and left running; kamal deploy does not restart them7. Schema migrations are your responsibility — run them as a deploy hook or a one-off kamal app exec command.

Step 9 — Enable automatic Let's Encrypt SSL

You already enabled HTTPS in Step 4 with a single line — ssl: true under proxy:. When that flag is set, kamal-proxy automatically requests and renews a Let's Encrypt certificate for your host, with no certbot, no cron job, and no manual renewal3. By default it also redirects all HTTP traffic to HTTPS.

Two behaviors to keep in mind:

  • Header forwarding changes. With ssl: true, kamal-proxy stops forwarding X-Forwarded-For and X-Forwarded-Proto to your app unless you explicitly add forward_headers: true3. If your Express app reads the client IP via req.ip behind the proxy, set forward_headers: true and call app.set('trust proxy', true).
  • Disabling the redirect. If you terminate TLS elsewhere and want HTTP passed through, set ssl_redirect: false3.

If you need a non-default health path, the proxy healthcheck is configurable. Our app already serves /up, which is the default, so no extra config is needed3:

proxy:
  host: notes.example.com
  app_port: 3000
  ssl: true
  healthcheck:
    path: /up
    interval: 3
    timeout: 3

Verification

Confirm the full stack works end to end. From your local machine:

# 1. Health endpoint should return 200 over HTTPS.
curl -i https://notes.example.com/up
# HTTP/2 200 ... OK

# 2. Create a note (exercises the Postgres accessory).
curl -s -X POST https://notes.example.com/notes \
  -H 'Content-Type: application/json' \
  -d '{"body":"deployed with Kamal"}'
# {"id":1,"body":"deployed with Kamal","created_at":"2026-06-21T..."}

# 3. The root route should now report one note.
curl -s https://notes.example.com/
# {"message":"notes-api is live","notes":1}

Then check the server-side view with Kamal's own tooling:

kamal details          # show running app + accessory containers
kamal app logs -f      # tail the app logs
kamal accessory logs db   # check Postgres logs

If kamal details shows your app container running and curl https://notes.example.com/up returns 200, the deploy succeeded. To verify the certificate, curl -vI https://notes.example.com and confirm the issuer is Let's Encrypt.

Troubleshooting

These are the five failures people hit most often on a first Kamal deploy, drawn from the Kamal docs and community discussions.

1. exec format error when the container starts. You built an arm64 image (Apple Silicon) and deployed to an x86-64 server. Set builder: { arch: amd64 } in deploy.yml and redeploy4.

2. Let's Encrypt certificate never issues. The ACME challenge needs your domain's A record to point at the server and port 443 to be open3. Run dig +short notes.example.com (must equal your server IP) and confirm ufw status allows 443. SSL also requires deploying to a single server with host set — it won't work across multiple hosts3.

3. App can't reach the database (ECONNREFUSED notes-api-db). The host in DATABASE_URL must be the accessory's container name, <service>-<accessory> — here notes-api-db7. Confirm both containers are on the kamal network with kamal accessory logs db and docker network inspect kamal on the server. The retry loop in waitForDb() also covers the brief window where Postgres is still starting.

4. Health checks time out and the deploy rolls back. kamal-proxy polls GET /up and gives up at the deploy timeout3. Make sure app_port matches the port your app actually listens on (3000 here) and that /up returns 200 without requiring the database. Tail kamal app logs to see why the container exited.

5. Postgres data disappears after a redeploy. You mounted the old path. For postgres:18, the volume must target /var/lib/postgresql, not /var/lib/postgresql/data — the data directory moved in PostgreSQL 1889. Fix the directories: entry, then restore from backup if you already lost data.

To undo a bad release entirely, kamal rollback returns to the previous version, and kamal remove tears down the proxy, app, and accessories5.

Next steps and further reading

You now have a single-server, HTTPS, zero-downtime Node.js deployment with a managed Postgres accessory — for the price of one small VPS. From here you can add a CI pipeline that runs kamal deploy on every push, move secrets into a password manager with kamal secrets, or scale out by listing more hosts under servers: and adding a load balancer.

Sources

Footnotes

  1. Kamal on RubyGems — version 2.12.0, released June 18, 2026 — https://rubygems.org/gems/kamal 2

  2. Node.js Releases — Node 24 is Active LTS (released 2025-05-06, maintenance 2026-10-20, EOL 2028-04-30) — https://nodejs.org/en/about/previous-releases 2

  3. Kamal — Proxy configuration (host, app_port, ssl, forward_headers, ssl_redirect, healthcheck defaults) — https://kamal-deploy.org/docs/configuration/proxy/ 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  4. Kamal Installation (gem install, kamal init, what kamal setup does) — https://kamal-deploy.org/docs/installation/ 2 3 4 5 6 7

  5. Kamal — View all commands (kamal init, kamal setup, kamal deploy, kamal rollback, kamal remove) — https://kamal-deploy.org/docs/commands/view-all-commands/ 2 3

  6. Kamal — Environment variables and .kamal/secrets (dotenv, clear/secret, host-side env file) — https://kamal-deploy.org/docs/configuration/environment-variables/ 2 3

  7. Kamal — Accessories (naming <service>-<accessory>, kamal network default, port binding, no zero-downtime for accessories) — https://kamal-deploy.org/docs/configuration/accessories/ 2 3 4 5 6

  8. PostgreSQL 18 Released (September 25, 2025) — https://www.postgresql.org/about/news/postgresql-18-released-3142/ 2

  9. Official postgres Docker image — PGDATA and VOLUME moved to /var/lib/postgresql in 18+ — https://hub.docker.com/_/postgres/ 2