Next.js
Deploy a Next.js app on Skiffly with Railpack — App Router or Pages, `next start` or standalone output, `NEXT_PUBLIC_*` at build time, Postgres with Prisma or Drizzle, image optimization and caching notes.
A Next.js repository is a Node project to Railpack: it installs with the package manager your lockfile implies, runs npm run build (next build) and starts with npm start (next start). Nothing needs to be configured for a fresh create-next-app. The Node.js page covers versions, package managers and general Node behaviour; this one is about what is specific to Next.js.
Port#
next start reads PORT from the environment, so the app listens where Skiffly expects (the service port; 8080 when unset). Do not hardcode -p 3000 in the start script unless you also set the service port to 3000. next dev is not a production server; keep start: next start.
Rendering modes#
next.config.* | What Railpack does | Notes |
|---|---|---|
| default (server) | Node image, next start | SSR, route handlers, server actions, ISR all work; the ISR cache lives on the container's disk and is per replica |
output: "standalone" | same build; set the start command to node .next/standalone/server.js | smaller image; copy public and .next/static yourself in the build command, e.g. next build && cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ |
output: "export" | detected as a static site: the exported out/ directory is served by Caddy | no server features; see Static sites |
With standalone output the server also reads PORT and binds to 0.0.0.0 by default (set HOSTNAME=0.0.0.0 on older versions).
Environment variables at build time#
NEXT_PUBLIC_* values are inlined during next build. Skiffly passes every service variable to the Railpack build, so set them as ordinary service variables before deploying; changing one later needs a new build (skiffly up, Deploy in the dashboard, MCP deploy), not a restart. Server-only secrets (DATABASE_URL, AUTH_SECRET) are read at runtime and need no rebuild.
Useful runtime variables:
NEXT_TELEMETRY_DISABLED=1
AUTH_URL=https://${{self.SKIFFLY_PUBLIC_DOMAIN}} # NextAuth / Auth.js canonical URLBind the trusted host list (AUTH_TRUST_HOST=true, serverActions.allowedOrigins) to your custom domain once you add one.
Database#
skiffly deploy --template postgres
skiffly variables set 'DATABASE_URL=${{Postgres.DATABASE_URL}}'- Prisma: keep
prisma generateinpostinstall(or"build": "prisma generate && next build"). Migrate from the start command:sh -c "npx prisma migrate deploy && next start", or as a one-off withskiffly ssh -- npx prisma migrate deploy. There is no release phase. - Drizzle:
drizzle-kit migratein the same place;drizzle-kit pushis fine for prototypes. - Connection pooling: one Next.js replica opens a handful of connections; with several replicas and serverless-style route handlers a pooler (
pgbouncertemplate) keeps Postgres calm.
The database is reachable only on the private network; next build must not query it (avoid top-level DB calls in statically generated pages, or mark them dynamic = "force-dynamic").
Images, fonts and caching#
next/imageoptimization runs in the Node process;sharpis installed by Next.js itself (npm i sharpon older versions). Allow remote hosts inimages.remotePatterns.next/fontdownloads Google fonts at build time — the build has internet access, so this works.fetchcache and ISR are stored under.next/cacheon the container's ephemeral disk: a redeploy starts empty, and replicas do not share it. For a shared cache use a customcacheHandlerbacked by Redis (skiffly deploy --template redis).
Health check#
Set the healthcheck path to / or to a light route handler (app/healthz/route.ts returning Response.json({ ok: true })). Avoid pages that hit the database on every probe.
Monorepos (Turborepo, pnpm workspaces)#
Set the service's root directory to the app (apps/web) only when the app is self-contained. When it imports workspace packages, keep the root at the repository, and set the build and start commands explicitly:
build: pnpm install --frozen-lockfile && pnpm --filter web build
start: pnpm --filter web startRailpack detects pnpm-workspace.yaml and installs from the root; next.config with transpilePackages or outputFileTracingRoot handles the rest. The monorepo tutorial walks through a complete setup.
Common problems#
| Symptom | Fix |
|---|---|
Deployment FAILED, log shows Ready on http://localhost:3000 but no traffic | -p 3000 hardcoded in start while the service port is 8080: remove the flag or set the port to 3000 |
NEXT_PUBLIC_API_URL is undefined in the browser | the variable was added after the build; trigger a new build |
Error: Cannot find module 'sharp' | add sharp to dependencies |
Build killed (Killed, exit 137) during next build | lower experimental.cpus in next.config or set NODE_OPTIONS=--max-old-space-size=2048 as a service variable (it reaches the build) |
Invalid src prop … hostname not configured | add the host to images.remotePatterns |
| Server actions fail behind a custom domain | AUTH_TRUST_HOST=true / experimental.serverActions.allowedOrigins |
Example#
import { defineSkiffly, generate, github, postgres, project, service } from "@skiffly/config";
export default defineSkiffly(() => {
const db = postgres("db");
const web = service("web", {
source: github("acme/shop"),
start: "sh -c 'npx prisma migrate deploy && next start'",
healthcheck: "/healthz",
port: 3000,
env: { NODE_ENV: "production", DATABASE_URL: db.env.DATABASE_URL, AUTH_SECRET: generate("hex32"), AUTH_URL: "https://${{self.SKIFFLY_PUBLIC_DOMAIN}}" },
domain: true,
});
return project("shop", { resources: [db, web] });
});Step by step: Next.js + Postgres tutorial. Landing: /deploy/nextjs.