Nub augments the Node already in your image — it is not a separate runtime. The official images start from an official node base and layer Nub on top, so node, npm, and nub are all present.

Official images

Dockerfile
FROM ghcr.io/nubjs/nub:0.7.5
COPY --chown=node:node package.json package-lock.json ./
RUN nub ci
COPY --chown=node:node . .
CMD ["nub", "run", "start"]

The image runs as the non-root node user, so copy your project in with --chown=node:node — a bare COPY . . lands root-owned files that nub install then can't write alongside.

Two variants, both on the current Node release line, digest-pinned and published for linux/amd64 and linux/arm64:

TagBaselibc
latest, <version>, slim, <version>-slimnode:26-slimglibc
alpine, <version>-alpinenode:26-alpinemusl

Run a TypeScript file directly — the entrypoint passes any non-command argument to nub:

docker run --rm -v "$PWD:/app" ghcr.io/nubjs/nub script.ts

Signals reach the runtime: docker stop delivers SIGTERM to nub, which forwards it to the Node process for a clean shutdown. The node user is uid 1000, so when you bind-mount a directory that nub install must write into, run with --user "$(id -u)" and the writes land with your host ownership.

Adding Nub to your own image

If you already build on a node base, install Nub with one line. At install time, npm selects the correct per-platform binary — including the musl build on Alpine — and the package's postinstall sets the execute bit, so installing as root and then dropping to a non-root user works.

Dockerfile
FROM node:26-slim
RUN npm install -g @nubjs/nub

On Alpine, add libgcc and libstdc++ for the native addon:

Dockerfile
FROM node:26-alpine
RUN apk add --no-cache libgcc libstdc++ && npm install -g @nubjs/nub

To pin the floor Node version instead of the current line, use a 22 base (node:22-slim or node:22-alpine) — the lowest version Nub's fast tier supports.

Web services

Container platforms route traffic to the port they provide through PORT. Bind the server to 0.0.0.0, not 127.0.0.1:

server.ts
import { createServer } from "node:http";

const host = "0.0.0.0";
const port = Number(process.env.PORT || 8080);

createServer((request, response) => {
  if (request.url !== "/alive") {
    response.writeHead(404);
    response.end();
    return;
  }

  response.writeHead(200, { "content-type": "application/json" });
  response.end(JSON.stringify({
    alive: true,
    runtime: "nub-on-node",
  }));
}).listen(port, host);

Copy manifests before source files so dependency installation stays cached. The image runs as node, so use --chown=node:node for files the install must write beside:

Dockerfile
FROM ghcr.io/nubjs/nub:0.7.5

COPY --chown=node:node package.json package-lock.json ./
RUN nub ci

COPY --chown=node:node . .
EXPOSE 8080
CMD ["nub", "run", "start"]

Change package-lock.json to the project's existing lockfile. Exclude node_modules, .git, and local .env files with .dockerignore.

Multi-stage builds

Install with nub ci in the build stage. It writes a self-contained node_modules — real files, project-local links — that survives a COPY --from into the final stage. A plain nub install shares packages through a machine-global store the final stage doesn't have, so the copied tree would point at files that aren't there.

Dockerfile
FROM ghcr.io/nubjs/nub:0.7.5 AS build
COPY --chown=node:node package.json package-lock.json ./
RUN nub ci
COPY --chown=node:node . .
RUN nub run build

FROM ghcr.io/nubjs/nub:0.7.5
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./package.json
CMD ["nub", "run", "start"]

Read the full docs on docs.docker.com.