arrow_backBack to articles
Tech

A Beginner’s Guide to Deploying on VPS with Kamal 2

Right now, the main full‑stack frameworks I use are Next.js 16 and Rails 8. Through Rails, I came across the officially recommended deployment tool: Kamal. I usually use it to deploy projects on RN and Alibaba Cloud servers. It feels smooth and simple to work with, so I’d like to introduce Kamal2’s core concepts and basic usage to you as well.

This article won’t start with anything too complex. I mainly want to clarify the core of Kamal2 first: what it actually is, what it can do for us, how to get started with the basics, and which commands you’ll use most often. At the end, I’ll walk through a Next.js example so you can see how these ideas map to a concrete project.

What is Kamal2?

In one sentence, Kamal2 is a tool that deploys applications using SSH + Docker. It doesn’t provide a full platform like Heroku, and it’s not a heavyweight orchestration system like Kubernetes. Instead, it’s more like a way to turn “deploying a bunch of Docker containers” into a set of repeatable commands and configuration.

The core idea behind Kamal is actually very straightforward:

This is also what I like about Kamal: it doesn’t introduce too much extra abstraction. You more or less know what it’s doing, which makes debugging a lot easier when something goes wrong.

When does Kamal2 make sense?

In my view, Kamal is especially suitable in these situations:

In other words, Kamal doesn’t “replace Docker” for you. It helps make your Docker deployments smoother.

A few core concepts to understand first

Before we dive in, I’d suggest getting familiar with these key concepts in Kamal.

config/deploy.yml

This is Kamal’s main configuration file, and all deployment configuration is read from here. You can think of it as the “deployment spec” for this release: service name, image name, server list, image registry, environment variables, builder, proxy, and so on all live here.

.kamal/secrets

This is the default location for the secrets file. Kamal reads sensitive variables from here. For example, your image registry password or Rails’s RAILS_MASTER_KEY usually should not be hard‑coded in deploy.yml, but injected via .kamal/secrets instead.

kamal setup

This is the single most important command for your first deployment. The docs are very clear: it connects to your server, installs Docker if needed, logs into the image registry, builds the image, pushes the image, pulls it on the server, starts kamal-proxy, starts the new container, and then switches traffic only after GET /up returns 200 OK.

kamal deploy

This is the command you’ll use most frequently after the first release. Once setup has run successfully, later deployments are typically done via kamal deploy.

Installing Kamal2

If you already have Ruby installed locally, the most straightforward way to install Kamal is:

bash
gem install kamal

This is the official, standard installation method. If you don’t have Ruby, you can also run Kamal in a Dockerized form, but the docs explicitly mention some limitations with that approach. For getting started, I recommend using the gem directly.

After installation, you can check the version:

bash
kamal version

Initializing a project

Once you’re in your project directory, run:

bash
kamal init

This command sets up the basic files Kamal needs. The most important ones are config/deploy.yml and .kamal/secrets.

If your project already has a Dockerfile, you’ve basically got the main prerequisite in place. Kamal’s deployment flow is built around using a standard Dockerfile in the project root to build images.

Minimal viable configuration

For beginners, I recommend starting with a very small config/deploy.yml. The docs show a minimal example that looks roughly like this:

yaml
service: myapp
image: your-registry-user/myapp

servers:
  - 203.0.113.10

registry:
  username: your-registry-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

env:
  secret:
    - AUTH_SECRET

In this config, these fields are the ones worth understanding first:

At this stage, don’t stress about making the configuration “complete”. What matters is getting the basic pipeline working end‑to‑end.

Configuring secrets

Next, you need to prepare .kamal/secrets. For example:

bash
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
AUTH_SECRET=your-auth-secret

If it’s a Rails project, the docs show reading RAILS_MASTER_KEY from config/master.key. That’s one reason Rails and Kamal fit together so naturally: a fresh Rails app is already quite easy to drop into this deployment model.

What happens on the first deployment?

Once both the configuration and secrets are ready, you can run:

bash
kamal setup

This command does quite a lot, but you can think of it as “a complete first-time deployment”. According to the official docs, it at least does the following:

There’s a very important detail here: the default health check is GET /up. So your application should expose a lightweight health check endpoint like this. Otherwise, the new container may be running, but Kamal still won’t switch traffic over.

Later deployments are much simpler

After setup succeeds once, subsequent updates usually just require:

bash
kamal deploy

The docs explicitly call this the command for subsequent deployments. You can think of it as your “normal release entry point”: rebuild the image, push it, pull it, start the new container, run the health checks, switch traffic, and stop the old container.

In terms of day‑to‑day experience, Kamal feels nicest in exactly this way: you invest a bit more effort for the very first deployment, and things become much smoother afterward.

Common operations

At the beginner stage, I find these commands the most useful:

Initialize

bash
kamal init

Generates the base deployment files.

First deployment

bash
kamal setup

Brings the server, Docker, image, proxy, and app online together for the first time.

Subsequent releases

bash
kamal deploy

The command you’ll probably run the most.

Multi‑environment deployment

If you later split your environments into staging and production, Kamal lets you specify a destination with -d, for example:

bash
kamal deploy -d staging

The docs explain that in this case, Kamal will merge config/deploy.staging.yml with the base config.

This feature is quite handy, but if you’re just getting started, it’s enough to know it exists—you don’t have to adopt it right away.

Why Kamal and Rails fit well together

New Rails 8 projects typically ship with a Dockerfile out of the box, and Kamal’s deployment flow is built directly around that Dockerfile. On top of that, the Rails ecosystem is already very comfortable with Ruby gems and CLI tooling, so discovering Kamal via Rails feels almost natural.

For me personally, seeing this workflow in a Rails project is what made me start treating it as a serious, long-term deployment option.

A basic Next.js example

To close things out, here’s a more day‑to‑day example for me: if I’m deploying a Next.js project with Kamal, I usually first make it a standard Dockerized app, then hand it over to Kamal for deployment.

Step 1: Make Next.js output standalone

The Next.js docs explain that when you enable output: 'standalone', the build produces a .next/standalone directory containing the minimal files needed to run the app and a server.js. This is great for Docker deployments because you don’t need to bundle your entire dev environment into the image.

In next.config.js:

js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

module.exports = nextConfig

One thing to note from the Next.js docs is that output: 'standalone' is meant to be run using the generated server.js, rather than continuing to use next start.

Step 2: Prepare the Dockerfile

A simple Next.js Dockerfile might look like this:

Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

The key isn’t Docker magic here, but the idea: first turn Next.js into a standard, runnable container, then let Kamal handle deployment.

Step 3: Write the Kamal config

For example:

yaml
service: my-next-app
image: your-registry-user/my-next-app

servers:
  - 203.0.113.10

registry:
  username: your-registry-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

env:
  clear:
    PORT: 3000
    NODE_ENV: production

The logic behind this config is straightforward:

Step 4: Add a health check endpoint

Because Kamal checks GET /up by default, I’ll add a simple route in Next.js, for example under the App Router:

ts
export async function GET() {
  return Response.json({ ok: true })
}

Place this under something like app/up/route.ts, so /up returns a simple success response. The lighter this endpoint is, the better—its only job is to tell Kamal “this container is ready to receive traffic”.

Step 5: Deploy

Finally, we just go back to Kamal’s standard flow:

bash
kamal setup

For subsequent updates:

bash
kamal deploy

If this flow runs smoothly for a Next.js project, you’ll notice something: Kamal doesn’t actually care whether you’re using Rails or not. What it cares about is whether you can provide a standard Docker image and a web service whose health it can check.

That’s it for now

If, like me, you came to Kamal through Rails 8 and gradually moved this approach over to Next.js 16, Kamal2’s role is pretty easy to understand: it’s not a platform, it’s a tool that makes self‑hosted Docker deployments more orderly.

For getting started, this is enough:

Once these pieces are running smoothly, moving on to multi‑environment setups, accessories, hooks, and more advanced proxy configuration will feel much more natural.