Kamal 2: Deploy Next.js to Any VPS with Zero Downtime

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Introduction

Deploying a web application to production shouldn't require a Kubernetes cluster, a dedicated DevOps engineer, or a four-figure cloud bill. Yet in 2026, many developers find themselves trapped between two extremes: expensive managed platforms (Vercel, Railway) and the complexity of Kubernetes.

Kamal 2 is 37signals' answer to this dilemma (the creators of Ruby on Rails and Basecamp). Formerly known as MRSK, Kamal is an open-source deployment tool that lets you deploy containerized applications to any server — VPS, bare metal, or cloud — with zero downtime, instant rollbacks, and automatic SSL via Traefik.

What makes Kamal 2 particularly compelling:

  • No vendor lock-in — works on any server with SSH and Docker
  • Zero downtime — new versions are started before cutting off the old ones
  • Simple configuration — a single YAML file (config/deploy.yml)
  • One-command rollback — return to the previous version instantly
  • Multi-server — deploy to multiple machines in parallel
  • Accessory support — manage your databases and auxiliary services

In this tutorial, you will:

  1. Install and configure Kamal 2 on your local machine
  2. Prepare a VPS for deployment
  3. Containerize a Next.js application with an optimized Dockerfile
  4. Configure Kamal for zero-downtime deployment
  5. Set up automatic SSL with Traefik
  6. Deploy, monitor, and perform rollbacks

Prerequisites

Before starting, make sure you have:

  • A VPS with at least 1 GB RAM and 1 vCPU (DigitalOcean, Hetzner, OVH, Contabo, etc.)
  • Ubuntu 22.04 or 24.04 installed on the VPS
  • A domain name pointing to your server (DNS A record)
  • Docker installed locally (to build images)
  • Ruby 3.1+ installed locally (Kamal is written in Ruby)
  • A Next.js application ready to deploy
  • A Docker Hub account (or another container registry)
  • Basic knowledge of Linux command line and Docker

What You'll Build

By the end of this tutorial, you'll have a complete deployment infrastructure:

  • A VPS automatically configured by Kamal
  • A Next.js application in production with SSL
  • A Traefik proxy handling routing and certificates
  • A zero-downtime deployment pipeline
  • The ability to rollback with a single command

Step 1: Install Kamal 2

Kamal is distributed as a Ruby gem. Install it globally on your development machine:

gem install kamal

Verify that Kamal is properly installed:

kamal version

You should see something like 2.4.0 or a newer version.

If you prefer not to install Ruby, you can use Kamal via Docker:

alias kamal='docker run -it --rm -v "${PWD}:/workdir" -v "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock" -e SSH_AUTH_SOCK="/run/host-services/ssh-auth.sock" -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/basecamp/kamal:latest'

Step 2: Prepare the VPS

2.1 Create the Server

Create a VPS from your preferred provider. For this tutorial, a server with these minimum specs is sufficient:

ResourceMinimumRecommended
RAM1 GB2 GB
CPU1 vCPU2 vCPUs
Storage20 GB SSD40 GB SSD
OSUbuntu 22.04Ubuntu 24.04

2.2 Configure DNS

Point your domain to the server:

Type: A
Name: app (or @ for root)
Value: <YOUR_SERVER_IP>
TTL: 300

2.3 Configure SSH

Make sure you can connect to the server via SSH with your key:

ssh root@your-server.com

Kamal connects via SSH to configure Docker and deploy your containers. It automatically handles Docker installation on the server during the first deployment — you don't need to install anything manually.

Step 3: Prepare the Next.js Application

3.1 Create the Project (if needed)

If you're starting from scratch:

npx create-next-app@latest my-kamal-app --typescript --tailwind --app
cd my-kamal-app

3.2 Create the Dockerfile

Create an optimized production Dockerfile at the project root:

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
 
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
 
RUN npm run build
 
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
 
CMD ["node", "server.js"]

3.3 Configure Next.js for Standalone Mode

Modify your next.config.js (or next.config.ts) to enable standalone mode:

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

The standalone mode generates an autonomous Node.js server that includes only the necessary files, significantly reducing the Docker image size.

3.4 Add a .dockerignore File

Create a .dockerignore file to speed up builds:

node_modules
.next
.git
.gitignore
*.md
docker-compose*.yml
.env*.local

Step 4: Initialize Kamal

4.1 Generate the Configuration

From your Next.js project root:

kamal init

This command creates the following files:

config/
  deploy.yml      # Main configuration
.kamal/
  secrets         # Secret environment variables
.env              # Local variables (added to .gitignore)

4.2 Configure deploy.yml

Replace the contents of config/deploy.yml with:

service: my-nextjs-app
 
image: your-docker-hub-user/my-nextjs-app
 
servers:
  web:
    hosts:
      - your-server.com
    labels:
      traefik.http.routers.my-nextjs-app.rule: Host(`app.your-domain.com`)
      traefik.http.routers.my-nextjs-app.entrypoints: websecure
      traefik.http.routers.my-nextjs-app.tls.certresolver: letsencrypt
    options:
      network: kamal
 
proxy:
  ssl: true
  host: app.your-domain.com
 
registry:
  username: your-docker-hub-user
  password:
    - KAMAL_REGISTRY_PASSWORD
 
env:
  clear:
    NODE_ENV: production
    HOSTNAME: 0.0.0.0
  secret:
    - DATABASE_URL
 
builder:
  dockerfile: Dockerfile
  multiarch: false
 
healthcheck:
  path: /api/health
  port: 3000
  interval: 10
  max_attempts: 30

Let's break down the important sections:

  • service: your application name (used to name containers)
  • image: the full path to your Docker image on the registry
  • servers: target server list with Traefik labels for routing
  • proxy: enables automatic SSL via Traefik and kamal-proxy
  • registry: your Docker registry credentials
  • env: environment variables (clear and secret separated)
  • healthcheck: Kamal verifies your app responds before switching traffic

4.3 Configure Secrets

Edit the .kamal/secrets file:

KAMAL_REGISTRY_PASSWORD=your-docker-hub-password
DATABASE_URL=postgresql://user:pass@db-host:5432/mydb

This file should never be committed to Git. Verify that .kamal/secrets is in your .gitignore.

4.4 Create the Healthcheck Endpoint

Kamal uses a healthcheck to verify your application is ready before switching traffic. Create a simple API endpoint:

// app/api/health/route.ts
import { NextResponse } from 'next/server'
 
export async function GET() {
  return NextResponse.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
  })
}

Step 5: Understanding Zero-Downtime Deployment

Before launching the first deployment, let's understand how Kamal 2 ensures zero downtime:

The Deployment Flow

1. Build Docker image locally (or in CI)
2. Push image to registry
3. Pull image on server(s)
4. Start new container
5. Healthcheck: Kamal waits for /api/health to return 200
6. kamal-proxy switches traffic to new container
7. Graceful shutdown of old container

The key point is step 6: traffic only switches after the new container passes the healthcheck. This means your users will never see a 502 error or blank page during deployment.

kamal-proxy vs Traefik

Kamal 2 uses kamal-proxy, a lightweight reverse proxy developed specifically for Kamal. It replaces the Traefik dependency for basic routing, although Traefik remains available as an option for advanced configurations (multi-service, middlewares, etc.).

kamal-proxy handles:

  • Traffic switching between old and new containers
  • SSL termination with Let's Encrypt
  • Automatic healthchecks
  • Connection draining for existing requests

Step 6: First Deployment

6.1 Run the Initial Setup

The first deployment requires a setup step that configures Docker and kamal-proxy on the server:

kamal setup

This command performs the following operations:

  1. SSH connection to the server
  2. Docker installation (if not present)
  3. kamal-proxy installation
  4. Docker registry login
  5. Image build and push
  6. Container pull and start
  7. SSL configuration

You'll see detailed output for each step:

  INFO [f97da026] Running docker login ...
  INFO [f97da026] Finished in 1.337 seconds
  INFO Building image ...
  INFO Pushing image ...
  INFO [48716498] Running docker pull ...
  INFO [48716498] Finished in 8.234 seconds
  INFO [48716498] Running docker run ...
  INFO Waiting for healthy container ...
  INFO Container is healthy!

6.2 Verify the Deployment

Once complete, verify everything is working:

# Check running containers
kamal details
 
# View application logs
kamal app logs
 
# Check proxy status
kamal proxy details

Visit https://app.your-domain.com — your Next.js application should be accessible with a valid SSL certificate.

Step 7: Subsequent Deployments

For subsequent deployments, a single command is all you need:

kamal deploy

That's it. Kamal will:

  1. Build the new image
  2. Push it to the registry
  3. Pull it on the server
  4. Start the new container
  5. Verify the healthcheck
  6. Switch traffic
  7. Stop the old container

Deploy Without Rebuilding the Image

If you've already built and pushed the image (e.g., in CI):

kamal deploy --skip-push

Step 8: Rollback

One of Kamal's greatest advantages is the ease of rollback. If a deployment causes issues:

# Roll back to a previous version
kamal rollback [VERSION_TAG]

To see available versions:

kamal app containers

Rollback is nearly instantaneous because the previous image is already present on the server. Kamal simply restarts the old container and switches traffic.

Step 9: Managing Accessories (Databases, Redis, etc.)

Kamal can manage auxiliary services called accessories. Add them to your config/deploy.yml:

accessories:
  db:
    image: postgres:16-alpine
    host: your-server.com
    port: "5432:5432"
    env:
      clear:
        POSTGRES_DB: my_app
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
    options:
      network: kamal
 
  redis:
    image: redis:7-alpine
    host: your-server.com
    port: "6379:6379"
    directories:
      - data:/data
    options:
      network: kamal

Deploy the accessories:

# Deploy all accessories
kamal accessory boot all
 
# Or a specific accessory
kamal accessory boot db

Accessories are persistent — they are not restarted during application deployments.

Step 10: Multi-Server Deployment

For high-traffic applications, you can deploy to multiple servers:

servers:
  web:
    hosts:
      - server-1.your-domain.com
      - server-2.your-domain.com
      - server-3.your-domain.com

Kamal deploys to all servers in parallel. Combined with a load balancer (like a DigitalOcean Load Balancer or DNS round-robin), you get high availability with no extra effort.

Server Roles

You can define different roles for your servers:

servers:
  web:
    hosts:
      - web-1.example.com
      - web-2.example.com
  worker:
    hosts:
      - worker-1.example.com
    cmd: npm run worker

Step 11: CI/CD Integration with GitHub Actions

Automate deployment with GitHub Actions:

# .github/workflows/deploy.yml
name: Deploy
 
on:
  push:
    branches: [main]
 
concurrency:
  group: deploy
  cancel-in-progress: true
 
env:
  DOCKER_BUILDKIT: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 20
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true
 
      - name: Install Kamal
        run: gem install kamal
 
      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
 
      - name: Deploy with Kamal
        env:
          KAMAL_REGISTRY_PASSWORD: ${{ secrets.DOCKER_HUB_TOKEN }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: kamal deploy

Add the following secrets to your GitHub repository:

  • SSH_PRIVATE_KEY: your SSH private key for the server
  • DOCKER_HUB_TOKEN: your Docker Hub token
  • DATABASE_URL: your database connection string

Step 12: Everyday Commands

Here are the Kamal commands you'll use most frequently:

Application Management

# Deploy
kamal deploy
 
# View logs in real-time
kamal app logs -f
 
# Execute a command in the container
kamal app exec "node -e 'console.log(process.env.NODE_ENV)'"
 
# Open an interactive session
kamal app exec -i bash
 
# Restart the application
kamal app boot
 
# Stop the application
kamal app stop

Proxy Management

# View proxy configuration
kamal proxy details
 
# Reload configuration
kamal proxy reboot

Accessory Management

# View database logs
kamal accessory logs db
 
# Restart Redis
kamal accessory reboot redis
 
# Run psql
kamal accessory exec db "psql -U postgres my_app"

Maintenance

# Clean up old Docker images
kamal prune all
 
# Lock deployments (maintenance)
kamal lock acquire -m "Maintenance in progress"
 
# Unlock
kamal lock release

Step 13: Monitoring Your Application

Advanced Healthcheck

Improve your healthcheck endpoint to verify dependencies:

// app/api/health/route.ts
import { NextResponse } from 'next/server'
 
interface HealthStatus {
  status: string
  timestamp: string
  uptime: number
  checks: {
    database?: string
    memory?: {
      used: number
      total: number
    }
  }
}
 
export async function GET() {
  const health: HealthStatus = {
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    checks: {},
  }
 
  // Check memory
  const memUsage = process.memoryUsage()
  health.checks.memory = {
    used: Math.round(memUsage.heapUsed / 1024 / 1024),
    total: Math.round(memUsage.heapTotal / 1024 / 1024),
  }
 
  return NextResponse.json(health)
}

External Monitoring

For comprehensive monitoring, you can add a monitoring service as an accessory:

accessories:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    host: your-server.com
    port: "3001:3001"
    directories:
      - data:/app/data
    options:
      network: kamal

Troubleshooting

Healthcheck Fails

If deployment fails with a healthcheck timeout:

  1. Check container logs:

    kamal app logs
  2. Test the healthcheck locally:

    docker build -t test-app .
    docker run -p 3000:3000 test-app
    curl http://localhost:3000/api/health
  3. Increase the timeout in deploy.yml:

    healthcheck:
      max_attempts: 60
      interval: 5

SSH Permission Error

If Kamal can't connect to the server:

# Check that your key is loaded
ssh-add -l
 
# Add your key if needed
ssh-add ~/.ssh/id_ed25519
 
# Test the connection
ssh root@your-server.com "echo OK"

Image Too Large

If the build or push takes too long, optimize your Dockerfile:

  • Use multi-stage build (already done in our Dockerfile)
  • Check your .dockerignore
  • Use node:20-alpine instead of node:20
  • Enable BuildKit for layer caching:
    export DOCKER_BUILDKIT=1

SSL Certificate Issues

If SSL isn't working:

  1. Verify DNS points to the server:

    dig app.your-domain.com
  2. Check proxy logs:

    kamal proxy logs
  3. Make sure ports 80 and 443 are open on the server firewall.

Kamal vs Alternatives

FeatureKamal 2CoolifyVercelKubernetes
Zero downtimeYesYesYesYes
Auto SSLYesYesYesComplex
Rollback1 commandUIAutomaticComplex
Multi-serverYesYesManagedYes
CostVPS onlyVPS onlyPer usageHigh
ComplexityLowLowVery lowVery high
Vendor lock-inNoneLowHighLow
Web UINoYesYesDashboard
Built-in CI/CDNo (via GH Actions)YesYesNo

Kamal stands out for its simplicity and zero vendor lock-in. A single YAML file, a few commands, and your application is in production. No web dashboard to manage, no cloud service to pay for — just SSH, Docker, and your server.

Next Steps

Now that your application is deployed with Kamal 2, here's how to go further:

  • Add a CDN like Cloudflare in front of your server to improve performance
  • Configure alerts with Uptime Kuma or Better Stack to be notified of issues
  • Set up a staging environment with a second configuration file (config/deploy.staging.yml)
  • Automate database backups with a cron job on the server
  • Explore Kamal hooks to run scripts before/after deployment (database migrations, cache invalidation, etc.)

Conclusion

Kamal 2 represents a refreshing approach to deployment: no unnecessary complexity, no vendor lock-in, no surprise bills. With a 5 euro per month VPS and a few minutes of configuration, you get a professional deployment pipeline with zero downtime.

Kamal's philosophy aligns perfectly with the growing movement toward self-hosting and digital sovereignty. You maintain full control of your data and infrastructure while enjoying a smooth developer experience.

Whether you're deploying a side project, a startup, or an enterprise application, Kamal 2 gives you the power you need without the unnecessary complexity. And if tomorrow you need to switch server providers, just modify one line in your configuration file.


Want to read more tutorials? Check out our latest tutorial on How to Generate Sound Effects Using ElevenLabs API in JavaScript.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles