containerflip (Node.js worker helper)
Make any Node HTTP server a well-behaved containerflip worker in one line. Zero dependencies, ESM, framework-agnostic.
containerflip is a blue/green deploy supervisor for a single host: a small process owns the listening TCP socket for its lifetime and hands each worker a duplicate of it as file descriptor 3, so worker versions can be swapped with zero dropped requests. This package implements the worker side of that contract, so your app cooperates with the supervisor:
- attach to the inherited, already-listening socket on fd 3 (or fall
back to
PORTfor standalone dev); - signal readiness with
READY=1over the$NOTIFY_SOCKETunix socket, once — and only once it can actually serve requests; - drain gracefully on SIGTERM — stop accepting, bounce keep-alive
connections with
Connection: close, and exit 0 once idle.
Install
npm install @gigacodes/containerflipRequires Node ≥ 18.2 (for http.Server.closeIdleConnections).
For local development against a checkout, npm link it or
npm install /path/to/clients/node.
Quick start
import http from 'node:http';
import { serve } from '@gigacodes/containerflip';
const server = http.createServer((req, res) => {
res.end('hello');
});
await serve(server); // attach fd 3 → send READY=1 → drain on SIGTERMThat's it. Under the supervisor it serves on the inherited socket; run directly
(node app.mjs) it falls back to PORT so you can poke it locally.
Framework recipes
The helper operates on the Node http.Server instance — which every framework
exposes. Build the server from your framework's request handler, then hand it to
serve().
Vanilla http
const server = http.createServer(handler);
await serve(server);Express
import express from 'express';
const app = express();
// ... app.get(...) etc.
const server = http.createServer(app); // NOT app.listen()
await serve(server);Koa
const server = http.createServer(app.callback());
await serve(server);TanStack Start (and other Nitro-based frameworks, e.g. Nuxt)
TanStack Start builds on Nitro, which by default emits a server that binds its
own port. Switch Nitro to the lower-level node-middleware preset so it
exports a Node listener instead, then mount that on a server you hand to
serve():
// app.config.ts — select the middleware preset (the config key is version-specific;
// newer Vite-based Start uses its nitro options, or build with NITRO_PRESET=node-middleware)
import { defineConfig } from '@tanstack/react-start/config';
export default defineConfig({ server: { preset: 'node-middleware' } });// server.mjs — your worker entry: `containerflip run --listen :8080 -- node server.mjs`
import http from 'node:http';
import { serve } from '@gigacodes/containerflip';
import { listener } from './.output/server/index.mjs'; // Nitro's node-middleware export
await serve(http.createServer(listener));The same shape works for anything that can hand you a Node request handler.
Frameworks that insist on calling .listen() themselves (e.g. Fastify)
Use the primitives instead of serve(): tell the framework to listen on fd 3,
then signal readiness and install the drain.
import { notifyReady, gracefulDrain, fdMode } from '@gigacodes/containerflip';
const server = fastify.server; // the underlying http.Server
gracefulDrain(server); // SIGTERM drain + Connection: close bounce
await fastify.listen(fdMode() ? { fd: 3 } : { port: Number(process.env.PORT ?? 0) });
await notifyReady(); // READY=1 — only after listeningnode:cluster (multi-core / per-worker crash isolation)
The cluster primary is the supervisor's direct child, so it owns the contract:
only it signals readiness — once all children are listening — and it fans the
drain out to them on SIGTERM. Strip NOTIFY_SOCKET from the children so a
child's notifyReady() no-ops and can't signal early.
import cluster from 'node:cluster';
import http from 'node:http';
import { serve, notifyReady } from '@gigacodes/containerflip';
const N = Number(process.env.CLUSTER_WORKERS ?? 4);
if (cluster.isPrimary) {
let listening = 0;
let draining = false;
const fork = () => cluster.fork({ NOTIFY_SOCKET: '' }); // children must not signal
for (let i = 0; i < N; i++) fork();
cluster.on('listening', () => {
if (++listening === N) notifyReady(); // aggregate readiness: all up
});
cluster.on('exit', () => {
if (!draining) fork(); // respawn steady-state crashes
else if (Object.keys(cluster.workers).length === 0) process.exit(0);
});
process.on('SIGTERM', () => {
draining = true;
for (const w of Object.values(cluster.workers)) w.process.kill('SIGTERM');
});
} else {
// A normal worker. listen({ fd: 3 }) inside a cluster child is resolved
// against the PRIMARY's fd 3 — the socket containerflip passed in — so the
// whole cluster serves on it. notify:false because only the primary signals.
const server = http.createServer((req, res) => res.end('hello'));
await serve(server, { notify: false });
}How it stays out of your way
The helper is deliberately non-invasive — it will not fight your framework, router, or middleware:
- It only touches the
Serveryou pass it. It never creates a server, never wraps or replaces your request handler, and never binds a port behind your back. - One header, while draining only. The sole request-path hook is a
prependListener('request')that setsConnection: closeonly while draining. It never reads or writes the body and never ends the response. Theprependensures it runs before handlers that flush headers synchronously. - Additive signal handling. It adds a
process.on(signal)listener; it never removes yours. Opt out entirely withsignals: []and calldrain()yourself. - No monkey-patching. It calls the original
net.Server.prototype.close(to avoid Node ≥19 destroying idle keep-alive connections onhttp.Server.close()) but patches nothing. No global state beyond a single draining flag, no env mutation, no dependencies.
API
serve(server, options?) → Promise<server>
One call: install the drain machinery, attach the accept loop (fd 3, or the
PORT fallback), then send READY=1 — in the order the contract requires
(attach first, ready second).
Options (all optional):
| Option | Default | Meaning |
|---|---|---|
fd |
3 |
Inherited socket file descriptor. |
port |
$PORT or ephemeral |
Standalone fallback port (used only when LISTEN_FDS is unset). |
graceMs |
$DRAIN_GRACE_MS or 28000 |
Drain grace period before idle connections are force-closed (kept ~2s under the supervisor's 30s drain deadline). |
signals |
['SIGTERM'] |
Signals that trigger the drain. [] installs only the Connection: close marker. |
notify |
true |
Send READY=1. Set false when a parent process signals readiness (e.g. a cluster primary). |
log |
stderr writer | Lifecycle logger (msg) => void, or false to silence. |
Primitives
For advanced wiring (clusters, custom signals, frameworks that own .listen()):
attach(server, { fd?, port? }) → Promise<server>— listen on fd 3, or the standalone fallback port. Rejects on listen error so you can exit non-zero (the supervisor then fails the deploy closed).notifyReady() → Promise<void>— sendREADY=1\nover$NOTIFY_SOCKET(stream mode). No-ops when unset. Call after the accept loop is attached.gracefulDrain(server, { graceMs?, signals?, log? })— install theConnection: closeresponse marker and signal handler(s) that drain then exit 0. Idempotent per server.drain(server, { graceMs?, log? }) → Promise<void>— drain per the contract without exiting; resolves when idle or at the grace deadline. Useful to drive shutdown yourself. Idempotent per server.isDraining() → boolean— whether a drain is in progress (process-global).fdMode() → boolean— whether the supervisor passed fd 3 (LISTEN_FDS ≥ 1).
A note on
notifyReady: it uses anAF_UNIX/SOCK_STREAMconnection because Node core cannot open datagram unix sockets. Configure the supervisor with--notify-mode=streamfor Node workers. The payload is identical tosd_notify'sREADY=1.
License
MIT 2026 Fabian Stelzer – Gigacodes GmbH. See LICENSE.
(The containerflip supervisor itself is GPL-3.0-or-later; the helper libraries are MIT on purpose, so importing one never imposes GPL terms on your app.)