Skip to content

Type to search pages.

View .md

Next.js

A Next.js application needs nothing Antifailure specific. It reads DATABASE_URL from its environment like any other service, and the manifest names the port and a health path:

services:
- name: web
kind: web
path: .
port: 3000
health_path: /api/health
migrate: "psql $DATABASE_URL -v ON_ERROR_STOP=1 -f migrations/0001_init.sql"

The working version of everything below is examples/next-app, and every one of these was found by running it rather than by reading it.

next build runs inside the image, where there is no database and no DATABASE_URL. Two habits from ordinary development break there.

A page that reads the database is rendered at build time unless it says otherwise, and rendering it then means connecting to one:

export const dynamic = "force-dynamic";

A connection pool created at module scope is opened when the module is imported, and next build imports every module it can reach. Create it on first use instead:

let pool: Pool | undefined;
export function db(): Pool {
if (!pool) pool = new Pool({ connectionString: process.env.DATABASE_URL });
return pool;
}

Without either one the image fails to build, with a connection error that reads like a configuration problem and is not one.

Standalone output leaves the static files behind

Section titled “Standalone output leaves the static files behind”

output: "standalone" produces a server and a pruned node_modules, which is what makes the runtime image worth scanning. It does not include the static assets. They are a second copy:

COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static

Leaving the second line out is the mistake everyone makes once. The page renders, arrives with no CSS, and looks like a styling bug.

Standalone picks its own root, and picks wrong in a monorepo

Section titled “Standalone picks its own root, and picks wrong in a monorepo”

Next decides where to write server.js by walking up from the project looking for lockfiles. In a repository that has its own above your application, it picks a root several directories too high and writes .next/standalone/<the whole path back down>/server.js rather than .next/standalone/server.js.

Inside a Docker build the context is one directory, so the inference is right and the Dockerfile works. On a laptop it is wrong. The artifact shape then depends on where somebody cloned the repository, which is not a thing anyone should have to know:

import path from "node:path";
const config: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(__dirname),
};

HOSTNAME decides whether anything can reach it

Section titled “HOSTNAME decides whether anything can reach it”

The standalone server binds to whatever HOSTNAME says. Left unset it has bound to localhost in some versions, and inside a container localhost means that container. The port is open and nothing outside can reach it, so the service starts cleanly and never becomes ready:

ENV HOSTNAME=0.0.0.0

Give it a health path that touches the database

Section titled “Give it a health path that touches the database”
export async function GET() {
try {
await db().query("SELECT 1");
} catch {
return Response.json({ status: "database unreachable" }, { status: 503 });
}
return Response.json({ status: "ok" });
}

The engine waits for health_path before it calls the service ready, so this is what makes ready mean the page will render. A health check that only proves a process is listening reports ready and then serves a stack trace.

Related: building services, the local runtime.