Deploy a Next.js application to a VPS with kamal

Last Change: 2025-09-19

  • Next.js version: 15.4.7
  • Ruby version 3.4.5
  • Kamal version: 2.7.0

Why

Vercel is a wonderful service. There is no simpler way to deploy a web application based on Next. There is just one problem you face as a European: It is a service run by a US company. That means it falls under US law regarding data sharing with the government and more often than not these days European customers are not willing to accept that. They want their data stored on servers located in Europe, run by European companies.

That means we have to leave the comfort of deploying with Vercel and all the awesome features it provides. There is no European version of it - not yet anyway. So we turn to the good old VPS and deploy there using Kamal.

I will describe how to deploy a simple Next.js app on a single VPS.

Setup

You need a Next.js project and kamal installed which requires Ruby. I recommend using rbenv.

After setting up Ruby run

gem install kamal

You also need Docker - I used the regular docker desktop application.

You will need a container repository somewhere as well. Since the goal of this exercise is to free oneself from US services I used the container registry available at the French cloud provider Scaleway.

Ideally you have a domain available to point to the VPS's IP address.

You will also have your VPS's private SSH key in your local keychain: ssh-add ~/.ssh/my_custom_key

The kamal proxy which runs in front of you application in this single-server setup requires your application to return OK on a path you can configure in config/deploy.yaml (more on that later). I solved this by adding a route at /api/ok

export async function GET() {
    return Response.json({ status: 'OK' }, { status: 200 });
}

Kamal expects your application to run on port 80, Next however runs on 3000 by default. Thus we have to change the package.json to start the application on port 80.

...
"scripts": {
    "dev": "next dev --turbopack",
    "build": "next build --turbopack",
    "start": "next start -p 80",
    "lint": "eslint"
  },
...

You need to change the next.config.tsas well to generate a standalone production build that runs efficiently in a container.

const nextConfig: NextConfig = {
  output: "standalone"
};

Containerization

Kamal essentially sets up your server to run an application in a docker container. This means that you need to make you application run in a docker container first. For Next this is fairly straightforward.

# syntax=docker.io/docker/dockerfile:1

FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
RUN npm ci


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1

RUN  npm run build

# Production image, copy all the files and run next
FROM base 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

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 80

ENV PORT=80

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

As you can see in this case I am using regular npm. If you would like to use another package manager you need to install it and modify this dockerfile accordingly. Please also remember to put in a .dockerignore file to avoid bundling things into the container that don't need to (or should) be bundled. This uses the small alpine node image. There are node images based on other distributions if you are so inclined.

This docker image is build by copying the application code, installing the dependencies and building the Next application. It then creates a non-root user to run the Next application we have build and then procedes to do so on port 80.

Kamal Configuration

On the top level of your Next project run kamal init. This will create all the necessary files you will need. Inside the folder config you will now find deploy.yaml.

service: kamal-next

image: kamal-test-namespace/kamal-next

servers:
  web:
    - 127.168.0.1

proxy:
  ssl: true
  host: kamal-next.yourdomain.eu
  healthcheck:
    interval: 3
    path: /api/ok
    timeout: 3

registry:
  server: rg.fr-par.scw.cloud
  username: nologin

  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

On the top you define a general service name. The image key references the namespace you configured in your docker repository and then the image name. The servers key contains the actual IP address of the VPS you provisioned. So prior to running this you need to spin up a VPS with the provider you would like to use. I used the second smallest VPS on Scaleway with 2 vCPU cores and 4 GB of RAM.

The proxy becomes necessary since this is a single server deployment. It runs in front of your application container, provides SSL encryption that is automatically set up as well as deployments free of interruptions. The healthcheck is configured to hit the endpoint we created above. It will only send requests there during deployment.

Under the registry key I put the URL of the image repository at Scaleway. The username is always nologin, the password is your secret key. To set it during build export it as a environment variable export KAMAL_REGISTRY_PASSWORD=my_scaleway_secret_key.

The builder key determines for which processor architecture the container is build. If you provisioned a VPC running on a ARM processor you need to change this to arm64.

Setup and Deployment

Kamal uses git to identify changes in your project. So git should be initialized and the changes committed.

For the initial setup run

kamal setup

Kamal will then proceed to build your image, install docker on your VPS, set the proxy up and then deploy your container. Make sure you have a stable connection. The first time I tried this was on a weak cellular connection and the push of the image to the registry failed a couple of times.

That's it. You now have your application running on a VPS. Congratulations!

If you want to deploy changes just commit and then run

kamal deploy