---
title: Deploy a Node.js App to a VPS with Kamal 2 (2026)
description: 'Deploy a Node.js app to a VPS with Kamal 2: a from-scratch Docker setup with zero-downtime deploys, automatic Let''s Encrypt SSL, and a Postgres accessory.'
url: https://nerdleveltech.com/deploy-nodejs-app-vps-kamal-2-tutorial
language: en
date: '2026-06-21'
category: cloud-devops
tags:
  - kamal
  - node.js
  - docker
  - vps
  - devops
  - postgres
  - cloud-devops
markdown_url: https://nerdleveltech.com/deploy-nodejs-app-vps-kamal-2-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/deploy-nodejs-app-vps-kamal-2-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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.0**[^kamal]. 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)[^node].
- **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 succeed[^proxy].

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 traffic[^proxy].

```bash
mkdir notes-api && cd notes-api
npm init -y
npm install express@5.2.1 pg@8.22.0
```

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

```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.22.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:

```js
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:

```js
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)[^node]:

```dockerfile
# 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:

```bash
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 RubyGems[^install]:

```bash
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)[^kamal]. If you'd rather not install Ruby, Kamal also ships as a Docker image, though that path has some limitations[^install].

Scaffold the configuration. From your project root:

```bash
kamal init
```

This creates a config stub at `config/deploy.yml` and a secrets stub in the `.kamal/` directory[^commands]. 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:

```yaml
# 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 explicitly[^proxy].
- **`ssl: true`** — turns on automatic Let's Encrypt certificates. It requires a single server and a `host` value that resolves to that server[^proxy]. 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 start[^install].

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:`[^proxy]. 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 substitution[^env]. Edit `.kamal/secrets`:

```bash
# .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)[^env]:

```bash
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:

```bash
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 boot[^env], 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 push[^accessories]. Add a Postgres 18 accessory to `config/deploy.yml`:

```yaml
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 recreation[^pg18][^pgvol].
- **`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 internet[^accessories].
- The accessory's container name is **`notes-api-db`** — the service name (`notes-api`) joined to the accessory key (`db`)[^accessories]. 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 default[^accessories]. 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:

```bash
dig +short notes.example.com
# should print 203.0.113.10
```

3. Make sure the server firewall allows inbound `80` and `443`. On a fresh Ubuntu box with `ufw`:

```bash
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 server[^proxy]. You do **not** need to install Docker yourself — `kamal setup` does that for you over SSH[^install].

## Step 8 — First deploy with kamal setup

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

```bash
kamal setup
```

`kamal setup` sets up all accessories, pushes the environment, and deploys the app to your servers[^commands]. 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 OK`[^install]. 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:

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

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

```bash
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 container[^proxy]. 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 OK`[^proxy]. Only then does the proxy switch traffic to the new container and stop the old one[^install]. 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 them[^accessories]. 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 renewal[^proxy]. 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: true`[^proxy]. 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: false`[^proxy].

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 needed[^proxy]:

```yaml
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:

```bash
# 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:

```bash
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 redeploy[^install].

**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 open[^proxy]. 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 hosts[^proxy].

**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-db`[^accessories]. 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 timeout[^proxy]. 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 18[^pg18][^pgvol]. 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 accessories[^commands].

## 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.

- [Deploy Bun + Hono to Fly.io in production](https://nerdleveltech.com/deploy-bun-hono-fly-io-production-tutorial) — the managed-PaaS alternative when you'd rather not run your own server
- [Caddy reverse proxy with Docker Compose and production HTTPS](https://nerdleveltech.com/caddy-reverse-proxy-docker-compose-production-https-tutorial) — a hand-rolled reverse-proxy approach to the same HTTPS problem
- [Cursor pagination with Postgres and Node.js](https://nerdleveltech.com/cursor-pagination-postgres-nodejs-tutorial) — build out the `/notes` API now that your database is live

## Sources

[^kamal]: Kamal on RubyGems — version 2.12.0, released June 18, 2026 — https://rubygems.org/gems/kamal
[^install]: Kamal Installation (gem install, `kamal init`, what `kamal setup` does) — https://kamal-deploy.org/docs/installation/
[^commands]: Kamal — View all commands (`kamal init`, `kamal setup`, `kamal deploy`, `kamal rollback`, `kamal remove`) — https://kamal-deploy.org/docs/commands/view-all-commands/
[^proxy]: Kamal — Proxy configuration (`host`, `app_port`, `ssl`, `forward_headers`, `ssl_redirect`, healthcheck defaults) — https://kamal-deploy.org/docs/configuration/proxy/
[^accessories]: Kamal — Accessories (naming `<service>-<accessory>`, `kamal` network default, port binding, no zero-downtime for accessories) — https://kamal-deploy.org/docs/configuration/accessories/
[^env]: Kamal — Environment variables and `.kamal/secrets` (dotenv, `clear`/`secret`, host-side env file) — https://kamal-deploy.org/docs/configuration/environment-variables/
[^node]: 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
[^pg18]: PostgreSQL 18 Released (September 25, 2025) — https://www.postgresql.org/about/news/postgresql-18-released-3142/
[^pgvol]: Official `postgres` Docker image — PGDATA and VOLUME moved to `/var/lib/postgresql` in 18+ — https://hub.docker.com/_/postgres/
