Node.js
Node.js is the runtime for running a Plumix site as an ordinary process: in a container, on a VM, on any host that runs Node. @plumix/runtime-node holds the adapter that bridges node:http to Plumix, plus one factory per slot it fills, and it uses what Node ships: node:sqlite for the database and the filesystem for uploads, so installing it compiles nothing.
Overview
Section titled “Overview”The runtime is a slot like any other. runtime: node() tells Plumix it is running as a process, and the rest of the package fills the slots beside it.
| Factory | Slot | What it wires up |
|---|---|---|
node() |
runtime |
The node:http server and the entry |
nodeSqlite({ path }) |
database |
A SQLite file through node:sqlite |
diskStorage({ dir }) |
storage |
A directory on disk for uploads |
images({ widths }) |
imageDelivery |
Resizing through sharp, served by the process |
Nothing fills kv yet; What Node does not do yet says what that means for a site. The cdn slot is not a runtime’s to fill — it names the shared cache in front of the site, and a process behind a CDN configures it exactly as a Worker does. CDN Caching is the path from a container to cached pages.
Install
Section titled “Install”pnpm add @plumix/runtime-nodenpm install @plumix/runtime-nodeyarn add @plumix/runtime-nodeNode 24.2 or newer: the runtime uses node:sqlite without a flag and import.meta.main to tell being run from being imported.
Quickstart
Section titled “Quickstart”This is the path from a scaffolded site to a running process. It assumes a site made with pnpm create plumix-app --runtime node.
-
Read the config the scaffolder wrote. Three slots and a passkey origin. The origin is a literal, because a process has no build environment to derive one from.
import { auth, plumix } from "plumix";import { diskStorage, node, nodeSqlite } from "@plumix/runtime-node";import { theme } from "./theme";export default plumix({runtime: node(),database: nodeSqlite({ path: "data/site.sqlite" }),storage: diskStorage({ dir: "data/media" }),auth: auth({passkey: {rpName: "recipes",// Passkeys are bound to the origin: change both to the host you deploy on.rpId: "localhost",origin: "http://localhost:3000",},}),plugins: [],theme,}); -
Create the database. The migrations are generated from your config and applied to the file
nodeSqlitenames, relative to the project root.Terminal window plumix migrate generateplumix migrate apply -
Develop. One Vite server, with the site behind Vite’s own middlewares: module serving, hot module replacement and the admin shell answer first, and everything else reaches the app through the same
node:httpbridge the built server uses. An edit to the config, the theme or a plugin is served on the next request, and a failing boot renders the dev error page instead of stopping the process. A.envin the project root is applied to the process environment, with a variable the shell already set winning, and re-applied when it changes; the built server reads only the process environment. The server answers loopback hosts only; setPLUMIX_DEV_ALLOW_REMOTE=1to review from another device, and add a named host to Vite’sserver.allowedHostsif you tunnel.Terminal window plumix dev --port 3000 -
Build. The build writes
dist/clientfor the browser anddist/server/worker.jsto run. Everything the server needs is inlined into that one file, except native packages, which it imports at runtime.Terminal window plumix build -
Run. The process listens on
PORTandHOST, defaulting to3000and0.0.0.0.Terminal window PORT=3000 node dist/server/worker.js -
Point the passkey at the deployed host. Before the first sign-in on a real host, set
rpIdto that host andoriginto itshttps://address, rebuild, and restart.
The process
Section titled “The process”Run directly, the entry serves dist/client from disk ahead of the site, then hands everything else to Plumix. Imported instead, it starts nothing: the default export is the portable { fetch, scheduled } handler, and listener(req, res) is the same site as a terminal node:http handler for embedding in Express, or in Fastify through @fastify/middie. It answers every request it receives and never calls next, so mount it where the site owns the path.
A rolling deploy that sends SIGTERM and waits ten seconds loses nothing that can finish in that window: the process stops accepting, stops the scheduler, lets in-flight responses finish and drains deferred work such as telemetry delivery, all within one ten-second budget, then exits 0. Whatever is still running at the deadline — a scheduled run, a response or deferred work — is cut, and the process exits 1 saying which. A second signal exits at once.
Behind a proxy
Section titled “Behind a proxy”Off by default, the server takes the scheme from its own socket, the host from Host, and the visitor’s address from the socket, so a visitor reaching the process directly cannot forge any of them. Behind a TLS-terminating reverse proxy that is wrong: every request looks like plain http from the proxy’s address, so the session cookie is not Secure, the passkey origin does not match, and sessions record the proxy. Turn the flag on:
import { node } from "@plumix/runtime-node";
export default { runtime: node({ trustProxy: true }),};With it on, the forwarded scheme, host and rightmost client address are believed. There is nothing more granular: the flag says whether the hop in front of the process is yours. node({ bodySizeLimit }) changes the 1 GiB cap on request bodies, which is enforced as a body streams rather than after it is buffered.
The database
Section titled “The database”nodeSqlite({ path }) opens the file for concurrent readers, which is what lets a backup, a test or a second process read it while the server writes. Two things to know before you move a file around. It is not portable between runtimes: Cloudflare’s D1 and this runtime record applied migrations in different tables, so generate and apply migrations on the runtime that will serve them. And for a remote or shared database, plumix/db/libsql stays selectable: point it at Turso or a libsql server and leave nodeSqlite out.
Storage
Section titled “Storage”diskStorage({ dir }) keeps uploads under the directory, and the media plugin serves them through its own route; there is no public URL and no presigned upload, so the site receives the bytes itself. It is single-node, like the SQLite file beside it. When a second process needs the same uploads, swap in s3() from plumix/storage/s3 with a bucket’s endpoint and credentials. It is one config line; nothing else changes.
Images
Section titled “Images”images() fills imageDelivery with the process itself: url() points at /_plumix/image, which the entry serves ahead of the site through sharp. The package is an optional peer, so add it beside the runtime; the scaffolder does when a plugin needs the slot, and a site without the slot installs nothing native. If it is missing, the first request fails naming the package.
A same-origin source, which is what a disk-stored upload has, is read through the site itself as an anonymous GET: what the media plugin gates stays gated, and nothing crosses the network. A remote source must match remotePatterns, the same shape images.remotePatterns takes for <Image>, and every redirect it takes is checked again, ten at most; anything else is 400. A source over 32 MiB, one that does not answer within fifteen seconds, or bytes that are not an image are refused with 413, 502 and 415. The width snaps up to the next entry in widths (320 to 1920 by default), quality is clamped, and the format follows Accept: AVIF, then WebP, then the source’s own. A variant is rendered once and kept under cacheDir (.cache/plumix/images) under a hash of the request; it goes out with an immutable cache header and an ETag that turns a revalidation into a 304.
import { images } from "@plumix/runtime-node";
export default { imageDelivery: images({ widths: [320, 640, 1280, 1920], remotePatterns: [{ hostname: "images.example.com" }], }),};The cache is one directory on one machine, like the uploads beside it; a fresh container starts cold and fills as pages are viewed. It holds at most cacheSize bytes (1 GiB by default): past that, the variant served longest ago is dropped; a restart only knows when each file was written, so it starts from that order. As many renders run at once as the machine has cores; the rest wait their turn, so a burst of first views cannot hold every source in memory together.
A variant does not outlive its source. When a media item is trashed or deleted, the media plugin asks the slot to forget that item’s variants, so the next request, a revalidation included, meets the serve route’s gating again instead of the cache. A browser that already holds a variant keeps it until its own cache expires, as with any immutable response.
Scheduled tasks
Section titled “Scheduled tasks”The process fires its own schedules. It reads them from app.scheduledTasks — the tasks core registers, such as publishing scheduled entries, plus whatever the site’s plugins add — and wakes on each UTC minute to fire the ones due. Nothing to configure: plumix build wires it, and the process starts it once it is listening.
Runs never overlap. Firings are serialised inside the process, so a task that runs longer than its own schedule delays the next firing rather than doubling up; the minutes it costs are logged and not replayed, so a container that was paused wakes up and carries on instead of stampeding through the backlog it missed. Across processes the guarantee holds too, because the guard lives in two rows of the site’s own database rather than in memory: replicas sharing one database — what plumix/db/libsql pointed at Turso gives you — contend there, and exactly one of them runs each firing. A replica killed mid-run releases its lease by expiry, so the schedule pauses for at most five minutes and then carries on somewhere else.
To hand the schedules to something else — a system cron, a Kubernetes CronJob — set cron: false and drive them from outside:
plumix cron list # the schedules this deploy declaresplumix cron run "*/5 * * * *" # fire one of them nowRead the list rather than writing one: the schedules come from the plugins a site installs, so a hand-written crontab goes stale the moment one is added. plumix cron run refuses an expression no task declares instead of exiting green having done nothing. It is also the quickest way to see a scheduled task run while developing, rather than waiting for its next minute to come round.
plumix cron run takes the same claim and lease the in-process scheduler does, so an invocation that overruns its schedule is not overlapped by the next one, and it says which it did — Skipped "…": another run holds the lease — rather than exiting green in silence. Two pods are only guarded against each other when they share a database, as they do on plumix/db/libsql; on the default nodeSqlite each pod has its own file and its own view of what has run.
You still want concurrencyPolicy: Forbid on a CronJob or flock on a crontab: those stop the second process before it opens a database at all, and the lease is what makes the run safe if they do not. The command releases its database connections when it finishes, so the process exits rather than lingering on an open socket.
plumix cron run exits non-zero when a task failed, naming it, so a CronJob’s own alerting sees it — a failing task is caught and logged so its siblings still run, and the command reports them at the end. A run that never reached its tasks at all says so separately. A skipped run exits zero: another process had the minute, which is the guard working rather than a failure. The in-process scheduler has no exit code to carry this, so it logs the same summary instead.
SIGTERM stops the scheduler before the drain begins, so no new firing starts behind it. A firing already in flight gets the shutdown’s budget to finish; one still running when that runs out is cut and logged, the process exits 1, and the run guard does not replay its minute.
Containers
Section titled “Containers”plumix build produces a directory and a file, not an image; nothing here scaffolds a Dockerfile, and none ships with create-plumix-app. This is the shape one takes, because the pieces above only add up to a working deploy when they are put together in the right order.
An Astro Node build is stateless — the image is the artefact, and a redeploy loses nothing. A Plumix Node deploy is not: three things on this page write to disk, and every one of them is gone on the next redeploy unless a volume survives it.
nodeSqlite({ path })— the database,data/site.sqlitein the scaffolddiskStorage({ dir })— uploads,data/mediaimages()— the resized-variant cache,.cache/plumix/imagesby default
Mount a volume over data/ and the database and uploads survive a redeploy. The image cache is different: losing it costs a cold cache, not data, as above — worth mounting too if the first view of every page after a deploy shouldn’t pay for a fresh render, but not required the way data/ is.
Migrate before the server starts
Section titled “Migrate before the server starts”plumix migrate apply needs the volume, which the build step does not have — a builder container has no mount, and a schema baked into the image can’t apply to whatever the volume already holds. Run it from the entrypoint, ahead of the server, using exec so the server process itself receives SIGTERM rather than a wrapping shell. As docker-entrypoint.sh:
#!/bin/shset -eplumix migrate applyexec node dist/server/worker.jsEnvironment and shutdown
Section titled “Environment and shutdown”PORT and HOST come from the environment, as in Quickstart; turn on trustProxy when an ingress or load balancer terminates TLS in front of the container. Pass secrets the same way — through the environment at container start, not a build-time ARG or ENV, which persists in the image’s layer history for anyone who can pull it.
Give the container more than the ten seconds the drain needs before it gives up: a terminationGracePeriodSeconds of 10 races the drain’s own deadline, so 30 leaves room for the SIGTERM to reach the process and the last response to leave before the platform sends SIGKILL.
One database, or many
Section titled “One database, or many”A single replica needs nothing beyond the volume above. Multiple replicas change what the no-overlap guarantee can promise: it holds only when every replica shares one database, which nodeSqlite’s file on a per-pod volume does not give you. Point database at plumix/db/libsql instead, or keep nodeSqlite and set cron: false with a single external trigger so only one replica ever runs a schedule.
A multi-stage Dockerfile
Section titled “A multi-stage Dockerfile”Two stages: one that installs every dependency and builds, one that installs only production dependencies and runs. The build stage’s node_modules and source never reach the image that ships. As Dockerfile:
FROM node:24-slim AS buildWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfileCOPY . .RUN pnpm build
FROM node:24-slim AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY package.json pnpm-lock.yaml ./RUN corepack enable && pnpm install --frozen-lockfile --prodCOPY --from=build /app/dist ./distCOPY docker-entrypoint.sh ./RUN chmod +x docker-entrypoint.sh
VOLUME /app/dataEXPOSE 3000ENTRYPOINT ["./docker-entrypoint.sh"]COPY package.json pnpm-lock.yaml ./ before COPY . . means an edit to source alone reuses the installed layer; a lockfile change is the only thing that busts it. Secrets are absent from both stages — they arrive with docker run --env-file or whatever the platform’s equivalent is, never as a build argument.
What Node does not do yet
Section titled “What Node does not do yet”There is no kv, so a plugin requiring that capability cannot be scaffolded on Node; the scaffolder says so by name. It is a single-node concern, as are the uploads and the image cache: this runtime is one process on one machine.
Public pages are a different story. A process with no CDN in front of it renders every page on every request, but that is a deployment left undone rather than a limit of this runtime — put the site behind a CDN, declare the cdn slot, and public pages cache and purge exactly as they do on Workers. The one thing a container does not get is the origin-side response store a Worker has, which is what a Workers deploy uses to cache a page per audience segment; behind a CDN alone, a non-anonymous segment renders live. CDN Caching covers both.
Commands the runtime adds
Section titled “Commands the runtime adds”plumix build builds the client first and the server second, because the server bakes the client’s asset manifest into its bundle. plumix migrate apply applies the generated migrations to the file nodeSqlite names. There is no deploy and no types: the deployment artefact is a directory and a command.
Related
Section titled “Related”Overview is the Cloudflare-shaped picture of a deploy; on Node there are no bindings, and PORT, HOST and the secrets arrive on process.env. CDN Caching is what to read next if this process will sit behind a CDN. Secrets covers the (env) => value resolver form, which reads the same environment here. Runtime Adapters is the contract this package implements, and Cloudflare Workers is the other implementation of it.
Next steps
Section titled “Next steps”Go to Secrets for what a secret-bearing slot looks like, then Configuration for every slot the factories above plug into, including the ones no Node adapter fills yet.