Deploy a Node.js App to a VPS with Kamal 2 (2026)
June 21, 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
/uphealth 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 initand understand thedeploy.ymlschema - 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
kamalnetwork - Ship your first zero-downtime deploy with
kamal setupand 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
Arecord 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 to80, 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 ahostvalue 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 foramd64or 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:18changed its data directory. As of PostgreSQL 18 the image's declared volume moved to/var/lib/postgresql(andPGDATAis 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 privatekamalnetwork 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 theDATABASE_URLfrom 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:
- In your DNS provider, create an
Arecord fornotes.example.compointing at203.0.113.10. - Confirm it has propagated:
dig +short notes.example.com
# should print 203.0.113.10
- Make sure the server firewall allows inbound
80and443. On a fresh Ubuntu box withufw:
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 forwardingX-Forwarded-ForandX-Forwarded-Prototo your app unless you explicitly addforward_headers: true3. If your Express app reads the client IP viareq.ipbehind the proxy, setforward_headers: trueand callapp.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.
- Deploy Bun + Hono to Fly.io in production — the managed-PaaS alternative when you'd rather not run your own server
- Caddy reverse proxy with Docker Compose and production HTTPS — a hand-rolled reverse-proxy approach to the same HTTPS problem
- Cursor pagination with Postgres and Node.js — build out the
/notesAPI now that your database is live
Sources
Footnotes
-
Kamal on RubyGems — version 2.12.0, released June 18, 2026 — https://rubygems.org/gems/kamal ↩ ↩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
-
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 -
Kamal Installation (gem install,
kamal init, whatkamal setupdoes) — https://kamal-deploy.org/docs/installation/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
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 -
Kamal — Environment variables and
.kamal/secrets(dotenv,clear/secret, host-side env file) — https://kamal-deploy.org/docs/configuration/environment-variables/ ↩ ↩2 ↩3 -
Kamal — Accessories (naming
<service>-<accessory>,kamalnetwork default, port binding, no zero-downtime for accessories) — https://kamal-deploy.org/docs/configuration/accessories/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
PostgreSQL 18 Released (September 25, 2025) — https://www.postgresql.org/about/news/postgresql-18-released-3142/ ↩ ↩2
-
Official
postgresDocker image — PGDATA and VOLUME moved to/var/lib/postgresqlin 18+ — https://hub.docker.com/_/postgres/ ↩ ↩2