Published on January 4, 2024

How to self-host a Next.js app on a Hetzner VM using Kamal

How to self-host a Next.js app on a Hetzner VM using Kamal

So you've built a Next.js app, and it's ready to be deployed... but where do you host it? If you're looking to steer clear of the big cloud providers for reasons of cost, privacy, or simply because you want more control, then this blog post is for you.

I'm going to walk you through the process of self-hosting your Next.js app on a Hetzner VM using Kamal. Of course, you can use any VM provider you like, but I'm going to use Hetzner as an example because I've been using them for years and I'm very happy with their service.

What is Kamal?

Before we get started, let's talk about Kamal. Kamal is a simple CLI tool developed by the folks over at 37 Signals that makes it easy to deploy your apps. It provides a simple configuration file that you can use to define your app's environment, and it takes care of the rest.

We're going to use a subset of Kamal's features to deploy our Next.js app. If you want to learn more about Kamal, check out their documentation.

Prerequisites

Before we get started, we need a few things. First, we need to install Kamal on our machine. Second, we need to create a new Next.js app. Finally, we need to Dockerize our Next.js app. Finally, we need a docker registry to store our Docker images, I'm going to use Docker Hub for this tutorial, but you can use any registry you like.

Install Kamal

To install Kamal, run the following command:

gem install kamal

Create a new Next.js app

To create a new Next.js app, run the following command:

npx create-next-app@latest

Setting up health checks

Before we can deploy our Next.js app, we need to set up health checks for it. This is necessary because Kamal uses health checks to determine whether our app is running or not. If our app is not running, Kamal will restart it automatically.

We need to define a custom endpoint that returns a 200 status code when our app is running and a 500 status code when it's not. To do that, open the app/up/route.ts file in the root of your project and add the following lines to it:

// app/up/route.ts
export async function GET(request: Request) {
return new Response('Ok', { status: 200 });
}

Over here, we are returning a 200 status code all the time. Ideally, we should check our app's health and return a 500 status code if it's not healthy. But for the sake of this tutorial, we're going to keep things simple.

Dockerzing our Next.js app

Kamal works with any app that can be run in a Docker container. So, before we can deploy our Next.js app, we need to Dockerize it.

First, open the next.config.js file in the root of your project and add the following lines to it:

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

module.exports = nextConfig;

Next, let's create a .dockerignore file in the root of our project and add the following lines to it. This will ensure that we don't include any unnecessary files in our Docker image and avoid some nasty surprises when we deploy our app.

Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.gitignore

Next, let's create a Dockerfile at the root of our project and add the following lines to it.

FROM node:20-alpine AS base

# Disabling Telemetry
ENV NEXT_TELEMETRY_DISABLED 1
RUN apk add --no-cache libc6-compat curl

FROM base AS deps
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next

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"]

This Dockerfile is based on this example from the Next.js docs. It's a bit more complicated than the one you might be used to, but it's necessary to ensure that our app runs correctly in a Docker container.

Let me explain what's going on here:

  • We're using the node:20-alpine image as our base image. This is a very small image that contains only the bare minimum to run Node.js apps.

  • We have four stages in our Dockerfile: base, deps, builder, and runner. The base is our base image that we use for all the other stages. The deps stage is where we install our app's dependencies. The builder stage is where we build our app. Finally, the runner stage is where we run our app.

  • Finally, we have a CMD instruction that tells Docker how to run our app. In this case, we're telling Docker to run node server.js.

Building our Docker image

Now that we have our Dockerfile, we can build our Docker image by running the following command:

docker build -t my-next-app .

To test that our Docker image works correctly, we can run the following command:

docker run -p 3000:3000 my-next-app

If everything went well, you should see the following output:

docker run -p 3000:3000 my-next-app
Next.js 14.0.4
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000

Ready in 52ms

If you open your browser and navigate to http://localhost:3000, you should see the default Next.js page or your app if you've already built it.

Setting up our VM

Ok, we got our Docker image working locally, now it's time to deploy it to our VM. To do that, we need to set up our VM first.

This is simple, head over to Hetzner or your cloud provider of choice and create a new VM. I'm going to create an Ubuntu 20.04 VM with 2GB of RAM and 2VCPU which will cost me a whopping 3.85€ per month.

Make sure to configure your VM with your SSH key so that you can SSH into it later, and you're good to go.

Ah don't forget to copy your VM's IP address, we'll need it later. For the sake of this tutorial, let's assume that our VM's IP address is 37.37.37.37.

Setting up Kamal

I'm going to assume you've been following along and have Kamal installed on your machine. If not, go ahead and install it now. Once you've done that, run the following command to initialize Kamal:

kamal init

This will create a config/deploy.yml file, a .env file if you don't already have one, a Dockerfile if you don't already have one, and a .kamal directory where we can define hooks that will be executed before and after our app is deployed.

Open the .env file and add your Docker Hub username and password to it:

# .env
KAMAL_REGISTRY_USERNAME=your-docker-hub-username
KAMAL_REGISTRY_PASSWORD=your-docker-hub-password

Next, update your config/deploy.yml file to look like this:

# config/deploy.yml
service: server
image: <username>/next-app (Your Docker Hub username and image name)
servers:
- 37.37.37.37 (Your VM's IP address)
registry:
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
port: 3000

Setting up our VM

That's it for Kamal, we're done with the configuration. This is where things get very simple, all thanks to Kamal. All we need to do is to run the following command:

kamal setup

This will SSH into our VM and set up everything that we need to deploy our app. Give it a few seconds to finish, and you're done.

Deploying our app

Last but not least, we need to deploy our app. To do that, run the following command:

kamal deploy

Give it a few seconds to finish, and you're done. You can now open your browser and navigate to your VM's IP address on port 80 to see your app running. If you've followed along, you should see the default Next.js page or your app if you've already built it.

Setting up a custom domain

There are a few ways to set up a custom domain for your app. You can configure the built-in Traefik reverse proxy to handle this for you, or you can use a third-party service like Cloudflare to handle this for you.

I usually default to Cloudflare because it's very easy to set up and provides a ton of other useful features like DNSSEC, DDoS protection, and more.

To set up a custom domain using Cloudflare, head over to Cloudflare and create an account if you don't already have one. Once you've done that, add your domain to Cloudflare, head over to your domain registrar and update your domain's nameservers to point to Cloudflare's nameservers. This will allow Cloudflare to handle all the DNS requests for your domain.

Finally, head over to your Cloudflare dashboard and add a new A record for your domain. Set the Name field to @ and the IPv4 address field to your VM's IP address. This will tell Cloudflare to route all requests for your domain to your VM. Make sure to set the Proxy status to Proxied so that Cloudflare can handle all the requests for your domain.

Give it a few minutes for the changes to propagate, and you're done. You can now open your browser and navigate to your domain to see your app running.

Conclusion

That's it, we're done. We've successfully deployed our Next.js app to a Hetzner VM using Kamal. I hope you found this tutorial useful, and I hope it helped you get started with self-hosting your apps.

If you're interested in learning more about Kamal, check out their documentation. They have a ton of useful information on how to use Kamal to deploy your apps.

Until next time, happy coding!

Interested in LogSnag?

Get started right now for free!