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.ts
as 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