Self-Hosting Next.js: The Production Guide

Sept. 30, 2024 (updated) by @anthonynsimon

While Vercel is a great option for deploying Next.js apps, sometimes you might want to deploy your app to your own server in order to save costs or simply to have more control over your infrastructure.

In this guide, I'll walk you step by step through how to self-host a production-grade Next.js web app on a VPS, with everything from automated SSL certificates to caching to automated rollouts using GitHub Actions.

Overview

While there are many ways to deploy a Next.js app, in this guide I'll use the following tools and services:

  • Docker: To run the app in a consistent environment, whether that's locally or in production. As a bonus, if you don't want to maintain a server, using Docker makes it easy to deploy to a managed container service like ECS, Cloud Run or Fly.io.
  • Kamal 2.0: An excellent tool by the fine folks at Basecamp. It automates zero-downtime deploys across multiple servers, sets up automatic SSL, plus you can manage other services like background workers and databases too.
  • Cloud provider of your choice: I'll use UpCloud in this guide (sponsor), but you can use any VPS provider you're comfortable with.
  • Cloudflare: To cache static assets globally. This will reduce the load on your server and speed up the app for users around the world.
  • GitHub Actions: To set up a CI/CD pipeline that builds and deploys the app whenever you push changes to the repo.

1. Dockerize your Next.js app

Let's start by creating a Docker image for your Next.js app. This will allow you to run your app in a consistent environment and easily deploy it to any server or cloud provider.

Fist, configure your Next.js app to build a standalone output so we keep the production image as small as possible. You can set this in your next.config.js file:

module.exports = {
  output: 'standalone',

  // ... rest of your config
}

Now, create a Dockerfile at the root of your project with the following contents:

# Builder image
FROM node:18-alpine AS builder

WORKDIR /app

# First install dependencies so we can cache them
RUN apk update && apk upgrade
RUN apk add curl

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

# Now copy the rest of the app and build it
COPY . .
RUN npm run build

# Production image
FROM node:18-alpine AS runner

WORKDIR /app

# Create a non-root user
RUN addgroup -S nonroot && adduser -S nonroot -G nonroot
USER nonroot

# Copy the standalone output from the builder image
COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
COPY --from=builder --chown=nonroot:nonroot /app/public ./public
COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static

# Prepare the app for production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"

EXPOSE 3000

# Start the app
CMD ["node", "server.js"]

Finally, create a .dockerignore file to exclude unnecessary files from the Docker build context:

node_modules
Dockerfile
README.md
.dockerignore
.git
.next
.env*
.kamal/secrets*

You should now be able to build and run your app using Docker:

docker build -t my-nextjs-app .

Try it out locally:

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

Open http://localhost:3000, you should see the Next.js welcome page.

Next, let's set up a server so other people can access it as well.

2. Set up an Ubuntu server

To host your Next.js app, you'll need a server. For this guide I'll use UpCloud, but you can use any provider you're comfortable with. These steps are similar across cloud providers as long as you stick to an Ubuntu server.

Create a new server

I'll use the smallest server. It costs €3/mo and comes with 1 CPU, 1 GB of memory and 1 TB of egress traffic - that's plenty for our purposes.

Select region to deploy to UpCloud

Don't forget to set your public SSH key before creating the server.

Add an SSH key on UpCloud

For reference, here's the command to create a new SSH key (public & private pair):

ssh-keygen -t ed25519 -C "[email protected]"

SSH into the server

Once your server is ready, grab the IP address and SSH into it with the key you configured.

ssh root@your-server-ip

Install Docker

First, update your package list:

apt update -y

Then install Docker:

# Add Docker's official GPG key and repository
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null

# Install Docker packages
apt update -y
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Docker should now be installed on your server. You can verify this by running docker --version.

Basic server security

While this is by far not an exhaustive security checklist, we want to reduce the risk of unauthorized access to your server with some basic precautions.

I also wrote a more detailed guide on how to secure an Ubuntu server. But here are some basic steps:

Disable password authentication:

sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/g' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config
echo 'ChallengeResponseAuthentication no' >> /etc/ssh/sshd_config

# Reload ssh agent
systemctl reload ssh

Enable the firewall:

ufw allow ssh
ufw allow http
ufw allow https
ufw enable

It might also be a good idea to check your cloud provider's firewall and configure it to only allow incoming traffic on ports 22, 80, and 443.

Also, don't forget to regularly update your server's packages. For example:

apt update && apt upgrade -y

3. Deploy your Next.js app

We need to somehow get your code into the server, build it and roll it out without any downtime. For this I'll use the open-source tool Kamal.

Why Kamal? It makes deployments easy and repeatable across projects whether you're using Next.js, Rails, Laravel, or anything else. It also handles automatic SSL, secrets, background workers, remote builds, and multiple servers - pretty neat.

First, install Kamal on your local machine:

gem install kamal

Now initialize your local Kamal config:

kamal init

This will create the config/deploy.yml and config/secrets files in your project. Now, replace the contents of the config file with the following (replace myrepo/example with the Docker image name you want, and 1.2.3.4 with your server's IP):

service: example
image: myrepo/example

env:
  secret:
    - EXAMPLE_SECRET

servers:
  - 1.2.3.4

proxy:
  app_port: 3000
  healthcheck:
    path: /
    interval: 5

registry:
  username:
    - DOCKER_USERNAME
  password:
    - DOCKER_PASSWORD

builder:
  arch: amd64
  remote: ssh://1.2.3.4
  cache:
    type: registry
    options: mode=max
    image: kamal-build-cache

asset_path: /app/.next

The asset_path part is important. It tells Kamal to combine the assets between deploys to avoid 404 errors. This is especially important for Next.js apps, as the filenames change with every build.

Now update the secrets in your .kamal/secrets file with the corresponding values:

EXAMPLE_SECRET=supersecret-123
DOCKER_USERNAME=SET_ME
DOCKER_PASSWORD=SET_ME

Now deploy your app for the first time with kamal setup, this will set up everything needed to run your app. Once ready, you should be able to access your app at http://your-server-ip.

That's great, but it's not quite ready yet. The app is serving requests unencrypted and over the server's IP. What we actually want is to serve it over HTTPS with a domain name.

Let's fix that next.

4. Serve your app over HTTPS

We'll use Kamal's built-in kamal-proxy to automatically generate and renew SSL certificates for the app.

Replace the config/deploy.yml file with the updated configuration:

service: example
image: myrepo/example

env:
  secret:
    - EXAMPLE_SECRET

servers:
  - 1.2.3.4

proxy:
  app_port: 3000 
  ssl: true
  host: example.com
  healthcheck:
    path: /
    interval: 5

registry:
  username:
    - DOCKER_USERNAME
  password:
    - DOCKER_PASSWORD

builder:
  arch: amd64
  remote: ssh://1.2.3.4
  cache:
    type: registry
    options: mode=max
    image: kamal-build-cache

asset_path: /app/.next

Important: Before you deploy these changes, you'll need to point your DNS records to your server's IP address. This is needed so Kamal can issue the SSL certificates for your domain.

For example, if you're using Cloudflare, you can add an A record with the following settings (replace example.com with your domain name and 1.2.3.4 with your server's IP):

Type Name Value Proxy status TTL
A example.com 1.2.3.4 DNS-only Automatic

Once you do this, you can deploy your updated app:

kamal deploy

Your app should now be accessible over HTTPS at your domain name.

5. Cache static assets with Cloudflare (optional)

You can use a CDN such as Cloudflare to cache your static assets close to the end users. That way, when your app gets a lot of traffic, the CDN serves cached assets from their own servers instead of hitting your app for every request.

Next.js does a lot of heavy lifting here too, because it generates filenames for static assets that can be cached for a long time (it appends a hash that only changes when the content changes).

Enable proxy-mode

To set this up, you need to be using Cloudflare to manage the DNS records for your domain. Once you've set this up, you can enable the CDN functionality by going to your A or CNAME record and setting the proxy status to "Proxied".

Enable proxy mode on Cloudflare

Now, when Next.js sends cache headers in the response, Cloudflare will look for them and cache the response accordingly.

Control cache via HTTP headers

You can also explicitly set cache headers in your Next.js app by using the Cache-Control header. For example, to cache an asset for 1 hour, you can set the header like this:

export async function getServerSideProps({ req, res }) {
    res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');

    return {
        props: {},
    }
}

Or you can set it globally, for example for all image files in your next.config.js:

module.exports = {
    async headers() {
        return [
            {
                source: "/:all*(svg|jpg|jpeg|png)",
                locale: false,
                headers: [
                    {
                        key: "Cache-Control",
                        value: "public, max-age=3600, must-revalidate",
                    },
                ],
            },
        ];
    },
};

6. Set up CI/CD with GitHub Actions

To make your deployment process smoother, you can set up a CI/CD pipeline to automatically build and deploy your app whenever you push changes to your repository.

For example, you can use GitHub Actions to build and push your Docker image to a container registry, and then automatically roll out the new image to your server.

To define a workflow, create a file at .github/workflows/deploy.yml with the following contents:

name: Deploy

on:
  push:
    branches: [ main ]

concurrency:
  group: deploy
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        ruby-version: ["3.2.2"]
        kamal-version: ["1.8.2"]
    env:
      DOCKER_BUILDKIT: 1

    steps:
    - uses: actions/checkout@v4

    - name: Set up Ruby ${{ matrix.ruby-version }}
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: ${{ matrix.ruby-version }}
        bundler-cache: true

    - name: Set up Kamal
      run: gem install kamal -v ${{ matrix.kamal-version }}

    - uses: webfactory/[email protected]
      with:
        ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

    - uses: docker/setup-buildx-action@v3
    - run: |
        kamal lock release
        kamal redeploy

Then go to your GitHub Repo > Settings > Secrets and add the following secrets:

  • EXAMPLE_SECRET: supersecret-123
  • DOCKER_USERNAME: Your Docker username
  • DOCKER_PASSWORD: Your Docker password
  • SSH_PRIVATE_KEY: Your private SSH key

Now, you need to update the .kamal/secrets file to read the secrets from the environment:

EXAMPLE_SECRET=${EXAMPLE_SECRET}
DOCKER_USERNAME=${DOCKER_USERNAME}
DOCKER_PASSWORD=${DOCKER_PASSWORD}

Finally, commit and push - your app will deploy automatically.

This is a minimal example, but you can include more steps like running tests, linting, or sending Slack notifications.

Conclusion

That's it - you now have a fully self-hosted Next.js app running on your own server, served securely over HTTPS, cached globally, and automatically deployed via GitHub Actions.

If you have any questions or feedback, feel free to reach out to me on Twitter.