Tutorial: Deploy a Next.js app

Vercel-shaped Next.js → Joule Cloud Compute. 15 minutes from git clone to a live URL with HTTPS, scale-to-zero, and per-request joule receipts.

Prerequisites

Step 1 — enable standalone output

In next.config.mjs:

const config = {
  output: "standalone",
};
export default config;

This makes Next emit a single-file server.js that requires only the standalone node_modules — perfect for a small container.

Step 2 — Dockerfile

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER 1001
EXPOSE 3000
CMD ["node", "server.js"]

Step 3 — invisible.hcl

workload "mysite" {
  image  = "ghcr.io/me/mysite:1.0"
  region = "auto"
  cpu    = "1 vCPU"
  memory = "1 GB"
  scale = {
    min = 0          # scale to zero like Vercel
    max = 30
    on_metric = "requests_per_sec > 50"
  }
  env = {
    NEXT_PUBLIC_API_BASE = "https://api.mysite.com"
  }
}

route "mysite.com" {
  to = workload.mysite
  https = true
  redirect_www = true
}

Step 4 — push + deploy

docker build -t ghcr.io/me/mysite:1.0 .
docker push ghcr.io/me/mysite:1.0
invisible deploy

The CLI prints the live URL once the workload is ready. First request to a scale-to-zero workload has a ~600ms cold start; subsequent requests are warm.

Multi-environment

Vercel branches → preview deploys. Our equivalent: name the workload per-environment and deploy from your CI per-branch.

# in CI: build with the branch ref in the tag
TAG=${GITHUB_SHA}
docker build -t ghcr.io/me/mysite:$TAG .
docker push ghcr.io/me/mysite:$TAG

# substitute into HCL
sed -i "s|image *= .*|image = \"ghcr.io/me/mysite:$TAG\"|" invisible.hcl

# different workload per env
NAME=mysite-$(echo $BRANCH | tr / -)
sed -i "s|workload \"mysite\"|workload \"$NAME\"|" invisible.hcl
sed -i "s|route \"mysite.com\"|route \"$NAME.example.com\"|" invisible.hcl

invisible deploy --auto-approve

Reading the joule footer

Server Actions and API routes get the response headers automatically through their fetch calls. To surface the energy per page to users:

// app/layout.tsx footer fragment
export default async function Layout({ children }: { children: ReactNode }) {
  const headers = await getResponseHeaders();
  return (
    <html>
      <body>
        {children}
        <footer className="text-xs text-gray-500">
          This page used {headers.get("x-energy-joules") ?? "—"} J
        </footer>
      </body>
    </html>
  );
}

(Where getResponseHeaders() is whatever helper you use to capture the response headers in your data-loading layer; depends on whether you're using Server Components, Route Handlers, etc.)

What you don't get vs Vercel

What you do get