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

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:
- Install and configure Kamal 2 on your local machine
- Prepare a VPS for deployment
- Containerize a Next.js application with an optimized Dockerfile
- Configure Kamal for zero-downtime deployment
- Set up automatic SSL with Traefik
- 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 kamalVerify that Kamal is properly installed:
kamal versionYou 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:
| Resource | Minimum | Recommended |
|---|---|---|
| RAM | 1 GB | 2 GB |
| CPU | 1 vCPU | 2 vCPUs |
| Storage | 20 GB SSD | 40 GB SSD |
| OS | Ubuntu 22.04 | Ubuntu 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.comKamal 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-app3.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 = nextConfigThe 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 initThis 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: 30Let'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/mydbThis 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 setupThis command performs the following operations:
- SSH connection to the server
- Docker installation (if not present)
- kamal-proxy installation
- Docker registry login
- Image build and push
- Container pull and start
- 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 detailsVisit 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 deployThat's it. Kamal will:
- Build the new image
- Push it to the registry
- Pull it on the server
- Start the new container
- Verify the healthcheck
- Switch traffic
- 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-pushStep 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 containersRollback 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: kamalDeploy the accessories:
# Deploy all accessories
kamal accessory boot all
# Or a specific accessory
kamal accessory boot dbAccessories 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.comKamal 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 workerStep 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 deployAdd the following secrets to your GitHub repository:
SSH_PRIVATE_KEY: your SSH private key for the serverDOCKER_HUB_TOKEN: your Docker Hub tokenDATABASE_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 stopProxy Management
# View proxy configuration
kamal proxy details
# Reload configuration
kamal proxy rebootAccessory 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 releaseStep 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: kamalTroubleshooting
Healthcheck Fails
If deployment fails with a healthcheck timeout:
-
Check container logs:
kamal app logs -
Test the healthcheck locally:
docker build -t test-app . docker run -p 3000:3000 test-app curl http://localhost:3000/api/health -
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-alpineinstead ofnode:20 - Enable BuildKit for layer caching:
export DOCKER_BUILDKIT=1
SSL Certificate Issues
If SSL isn't working:
-
Verify DNS points to the server:
dig app.your-domain.com -
Check proxy logs:
kamal proxy logs -
Make sure ports 80 and 443 are open on the server firewall.
Kamal vs Alternatives
| Feature | Kamal 2 | Coolify | Vercel | Kubernetes |
|---|---|---|---|---|
| Zero downtime | Yes | Yes | Yes | Yes |
| Auto SSL | Yes | Yes | Yes | Complex |
| Rollback | 1 command | UI | Automatic | Complex |
| Multi-server | Yes | Yes | Managed | Yes |
| Cost | VPS only | VPS only | Per usage | High |
| Complexity | Low | Low | Very low | Very high |
| Vendor lock-in | None | Low | High | Low |
| Web UI | No | Yes | Yes | Dashboard |
| Built-in CI/CD | No (via GH Actions) | Yes | Yes | No |
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.
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

Deploy a Next.js App with Coolify v4: Complete Self-Hosting Guide
Learn how to deploy your Next.js applications on your own server with Coolify v4, an open-source alternative to Vercel and Netlify. Installation, configuration, automatic SSL, CI/CD, and monitoring included.

Deploy a Next.js Application with Docker and CI/CD in Production
Learn how to containerize your Next.js application with Docker, set up a CI/CD pipeline with GitHub Actions, and deploy to production on a VPS. A complete guide from development to automated deployment.

Docker Compose for Full-Stack Developers: Next.js, PostgreSQL, and Redis
Learn how to containerize a full-stack Next.js application with PostgreSQL and Redis using Docker Compose. This hands-on tutorial covers multi-service orchestration, development workflows, hot reloading, health checks, and production-ready configurations.