back to home

December 8th 2024

Have $5? Let's Self-Host a Next.js app

Got $5 and a Next.js app you want to deploy? Let's skip Vercel and host it yourself.

Many developers struggle with self-hosting Next.js applications. The good news is that all Next.js features work when self-hosting with Docker — Server Actions, middleware (now called proxy in v16), internationalization, API routes — everything.

What You'll Need

Step 1: Configure Next.js for Standalone Output

In your next.config.ts, enable standalone output for an optimized production build:

const nextConfig = {
  output: 'standalone',
}

module.exports = nextConfig

Step 2: Set Up Your Server

Order an Ubuntu VPS and create an SSH connection.

Check if you have an SSH key:

ls ~/.ssh/id_rsa.pub

If not, generate one:

ssh-keygen -t rsa -b 4096 -C "your_email@example.com"

Copy your public key to the server:

cat ~/.ssh/id_rsa.pub | ssh root@your-server-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

SSH into your server and update packages:

ssh root@your-server-ip
apt update && apt upgrade -y

Step 3: Create the Dockerfile

Create a Dockerfile at your project root. This one works with npm, pnpm, or yarn:

FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

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

RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
  else echo "Lockfile not found." && exit 1; \
  fi

# Production image
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
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"]

Add a .dockerignore:

node_modules
.next
.git
*.md

Test locally:

docker build -t my-nextjs-app .
docker run -p 3000:3000 --env-file .env my-nextjs-app

Visit http://localhost:3000 to verify it works.

Step 4: Set Up Kamal

Install Kamal as a Docker alias in your ~/.zshrc:

alias kamal='docker run -it --rm -v "${PWD}:/workdir" -v "${HOME}/.ssh:/root/.ssh" -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'

Initialize Kamal in your project:

kamal init

Add your secrets to .kamal/secrets:

DOCKER_USERNAME=your-docker-username
DOCKER_PASSWORD=your-docker-password

Configure config/deploy.yml:

service: my-app
image: your-dockerhub-username/my-app

servers:
  web:
    hosts:
      - your-server-ip
    options:
      publish:
        - "3000:3000"

proxy:
  ssl: true
  host: yourdomain.com

registry:
  username: your-dockerhub-username
  password:
    - DOCKER_PASSWORD

builder:
  arch: amd64

asset_path: /app/.next/static

Important: The asset_path option tells Kamal to preserve assets between deploys, preventing 404 errors on hashed filenames.

Step 5: Point Your DNS

Add an A record in Cloudflare (or your DNS provider):

| Type | Name | Value | Proxy | |------|------|-------|-------| | A | @ | your-server-ip | DNS only | | A | www | your-server-ip | DNS only |

Step 6: Deploy

Commit your changes:

git add .
git commit -m "Add Docker and Kamal config"

Deploy for the first time:

kamal setup

For subsequent deploys:

kamal deploy

Step 7: Set Up CI/CD with GitHub Actions

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Install SSH key
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Deploy with Kamal
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
        run: |
          echo "DOCKER_USERNAME=$DOCKER_USERNAME" > .kamal/secrets
          echo "DOCKER_PASSWORD=$DOCKER_PASSWORD" >> .kamal/secrets
          
          docker run --rm \
            -v "${PWD}:/workdir" \
            -v "${SSH_AUTH_SOCK}:/ssh-agent" \
            -e SSH_AUTH_SOCK=/ssh-agent \
            ghcr.io/basecamp/kamal:latest deploy

Add these secrets to your GitHub repo (Settings → Secrets → Actions):

Now every push to main triggers a deployment.

Debugging

View running containers:

docker ps

Check logs:

docker logs <container-id>

Monitor resources:

docker stats

Running Multiple Apps on One Server

You can't run multiple apps on port 3000. Use different ports:

# Site 1 - port 3000
options:
  publish:
    - "3000:3000"

# Site 2 - port 3001
options:
  publish:
    - "3001:3000"

# Site 3 - port 3002
options:
  publish:
    - "3002:3000"

Then configure your reverse proxy (Nginx, Caddy, etc.) to route each domain to its port.

Optional: Cloudflare CDN

Once everything works, enable Cloudflare's proxy (orange cloud) on your DNS records to cache static assets at the edge. This significantly improves load times for users worldwide.


That's it. Your Next.js app is now self-hosted with zero-downtime deployments, CI/CD, and full feature support — all for about $5/month.