Node.js¶
NodeService runs a Node.js application from the source directory mounted in
the container. No PHP anywhere: this is the official node image, the package
manager of your choice through corepack, and a package.json script as the
container command.
The container runs <manager> run dev by default, which is exactly what serves
a Vite/React or Next.js development server with its own hot reload — the process
watches the mounted sources itself, so nothing on the host rebuilds anything.
(new NodeService('front'))
->withVersion('24') // tag of the official node image (default: 24)
->withDirectory(__DIR__ . '/front')
->withPackageManager(PackageManager::Pnpm) // npm (default), Yarn or Pnpm
->withPort(3000) // port the dev server listens on (default: 3000)
->withDomain('front.project.test')
->withHttpAccess() // also serve plain HTTP, without redirecting to HTTPS
castor docker:service:install node writes that block for you, and scaffolds
the application — see Scaffolding.
Listening on 0.0.0.0¶
A dev server bound to localhost answers only inside its own container, and the
router in front of it returns a 502. This is the one thing that catches
everybody.
The service sets HOST=0.0.0.0 and PORT=<port> in the container, which
Next.js, Nuxt and react-scripts read. Vite reads neither — tell it in
vite.config.js:
export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
// The page is served over HTTPS on 443 by the router, so the HMR client
// has to open its websocket there and not on the port Vite listens on.
hmr: { protocol: 'wss', clientPort: 443 },
},
})
Next.js needs one line of its own: it rejects dev requests coming from another origin than the one it listens on, which is what being served on a domain is.
// next.config.mjs
export default { allowedDevOrigins: ['front.project.test'] }
The package manager¶
->withPackageManager(PackageManager::Pnpm)
Corepack is enabled in the image whichever one you pick, so a project pinning
"packageManager": "pnpm@10.4.1" in its package.json gets that version
regardless — this setting only decides what the generated commands type, and
what the passthrough task is called (front:pnpm rather than front:npm).
Corepack downloads the manager on first use into the shared home directory
(COREPACK_HOME=/home/app/.cache/node/corepack), so it is fetched once for the
whole project and survives a rebuild of the image.
What the container runs¶
->withScript('start') // "<manager> run start" instead of "run dev"
->withRunCommand(['node', 'server.js']) // replaces the command, argument vector
->withRunCommand('npm run dev -- --host 0.0.0.0') // replaces the command, through a shell
->withInstallCommand('npm ci') // replaces "<manager> install"
->withBuildCommand('npm run build:prod') // replaces "<manager> run build"
Paths¶
Two settings, which only coincide in the simple case:
| Method | What it sets | Default |
|---|---|---|
withDirectory() |
the host directory mounted at /app |
. |
withWorkingDirectory() |
where the package.json lives, relative to the mount |
. |
They come apart as soon as the mount is bigger than the package — a monorepo whose front-end sits below the root it mounts:
(new NodeService('front'))
->withDirectory(__DIR__) // mount the repository
->withWorkingDirectory('apps/front')
When nothing reloads¶
->withPolling()
A bind mount does not carry inotify events across the virtual machine of Docker
Desktop, nor across a Windows filesystem mounted into WSL: the dev server
starts, serves, and then simply never notices an edit. withPolling() sets
CHOKIDAR_USEPOLLING (Vite, Nuxt) and WATCHPACK_POLLING (Next.js, webpack),
which costs CPU — which is why it is opt-in rather than the default.
Environment¶
->withEnvironment('API_URL', 'https://api.project.test')
->withEnvironment('SENTRY_DSN') // no value: passed through from your own environment
Anything set here wins over the defaults the service sets, so
->withEnvironment('PORT', '4000') is how you override one of them.
Behind the redirection.io agent¶
Declare the domain on the agent rather than on the application, and leave the service without one:
$front = (new NodeService('front'))
->withDirectory(__DIR__ . '/front')
->withPort(3000)
;
$event->addService($front);
$event->addService(
(new RedirectionioAgentService())
->addReverseProxy('front.project.test', $front, 'your-project-key')
);
The port comes from the service, so there is nothing to repeat — passing the
service name instead of the instance still needs port: 3000.
Generated tasks¶
castor front:install— install the dependencies (<manager> install)castor front:build— build the application (<manager> run build)castor front:restart— restart the servicecastor front:watch— restart on every change to a.js,.ts,.jsx,.tsxor.jsonfile. For a plainnode server.js; a dev server watches by itself and wants nothing of thiscastor front:npm— any command of the package manager, named after it (front:pnpm,front:yarn)castor front:bash— a bash shell in the container
Containers¶
front— the Node application, sources mounted at/app, shared home directory at/home/app
node_modules lives in your sources through the mount, like every other build
artifact of the plugin: it survives container recreation, and the editor on the
host resolves imports. Install it from the container
(castor front:install) — a npm install run on a macOS or Windows host writes
native binaries the Linux container cannot execute.
Scaffolding a new application¶
castor docker:service:install node asks for a template and creates the
application before the first up, so the container serves something as soon as
the install finishes:
| Template | What it runs |
|---|---|
none |
a dependency-free node --watch server.js, listening on the port you chose |
vite-react |
create-vite --template react, plus the vite.config.js shown above |
next |
create-next-app, plus the allowedDevOrigins line |
Dockerfile extension points¶
NodeService is built from a Twig
Dockerfile you can extend:
# syntax=ghcr.io/castor-php/twig-dockerfile:0.1
{% extends 'Dockerfile' %}
{% block node_base %}
{{ parent() }}
RUN apt-get update \
&& apt-get install -y --no-install-recommends imagemagick \
&& rm -rf /var/lib/apt/lists/*
{% endblock %}
Then ->withDockerfile(__DIR__ . '/Dockerfile'). This is also how you install
extra Debian packages — there is no addAptPackage(), the block is the
extension point.
Blocks¶
| Block | Stage | What it holds |
|---|---|---|
node_base |
node-base |
the official node image, corepack, WORKDIR /app |
runtime |
runtime |
FROM node-base, the stage the container runs |
Variables¶
| Variable | Type | Comes from |
|---|---|---|
package_manager |
npm, yarn or pnpm |
withPackageManager() |
The version is not a Twig variable: it reaches FROM through the
node_version Docker build argument and shell interpolation. Build arguments
are JSON-decoded before Twig sees them, which would turn "20.10" into the
number 20.1 and pull the wrong image.