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
- A Next.js 14+ app (
app/directory). The pages directory works too. - A container registry your CI can push to (GitHub Container Registry, Forgejo Container Registry, Docker Hub, ECR, etc.).
jc logindone.jc whoamireports your account.
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
- Auto preview-deploy-per-PR — you script it in CI (see above).
- Built-in analytics — bring your own (Plausible, Umami, etc.).
- Vercel Edge runtime — we're on V8 isolates in Functions instead; full Node in Compute.
What you do get
- Joule billing — idle Next servers cost near-zero, not "$X / GB-second of "VFC" rounded up"
- Region pinning that meets compliance, not just optimization
- One billing surface for site + AI calls + database + storage