Skip to content

Commit 10a329f

Browse files
committed
docs: add Node.js deploy guide and runnable example for Datum compute
1 parent 5852ed0 commit 10a329f

8 files changed

Lines changed: 512 additions & 0 deletions

File tree

docs/guides/deploy-a-node-app.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# Deploy a Node.js Web Service on Datum Compute
2+
3+
> Last verified: 2026-06-02 against the `hello-node` example and the live `kraft` / `datumctl compute` CLIs.
4+
> The complete, ready-to-deploy example for this guide lives in [`examples/hello-node/`](../../examples/hello-node/).
5+
6+
This guide walks you through taking a Node.js HTTP service from source code to a live, reachable instance on Datum compute. By the end you will have:
7+
8+
- A Node.js application packaged as a Unikraft unikernel image
9+
- The image published to the Unikraft Cloud metro registry
10+
- A running workload deployed with `datumctl compute deploy`
11+
- A verified HTTP response from your instance
12+
13+
**What you need before starting:**
14+
15+
- `kraft` (KraftKit) installed and authenticated to your Unikraft Cloud metro. The metro URL and token are supplied to `kraft cloud` commands; this guide assumes they are available as `$UKC_METRO` and `$UKC_TOKEN` in your shell.
16+
- `datumctl` installed with the compute plugin, authenticated to your Datum Cloud project.
17+
- Docker (with BuildKit) running locally.
18+
- Node.js (for local development only — the build happens inside Docker).
19+
20+
---
21+
22+
## 1. Write the application
23+
24+
Create a project directory and add two files.
25+
26+
**`app.js`**
27+
28+
```js
29+
const http = require('http');
30+
31+
const port = parseInt(process.env.PORT, 10) || 8080;
32+
33+
const server = http.createServer((req, res) => {
34+
if (req.url === '/healthz') {
35+
res.writeHead(200, { 'Content-Type': 'text/plain' });
36+
res.end('ok\n');
37+
return;
38+
}
39+
res.writeHead(200, { 'Content-Type': 'text/plain' });
40+
res.end('Hello from Datum (Node)\n');
41+
});
42+
43+
server.listen(port, () => {
44+
console.log('listening on :' + port);
45+
});
46+
```
47+
48+
**`package.json`**
49+
50+
```json
51+
{
52+
"name": "hello-node",
53+
"version": "1.0.0",
54+
"private": true,
55+
"main": "app.js",
56+
"scripts": {
57+
"start": "node app.js"
58+
}
59+
}
60+
```
61+
62+
The service listens on `$PORT` (default `8080`), answers `/healthz` with `ok`, and has no external dependencies.
63+
64+
---
65+
66+
## 2. Build and publish the unikernel image with `kraft`
67+
68+
### Why Node runs on the `base-compat` runtime
69+
70+
Datum's Unikraft runtime uses an app-elfloader that loads your application as the unikernel entrypoint. Compiled languages (Go, Rust) ship a single fully static binary on the `base:latest` runtime. Node is different: the `node` interpreter is a **dynamically linked** executable — it needs its loader (`ld-musl`) and a few shared libraries present at boot.
71+
72+
For that, Node uses the **`base-compat:latest`** runtime (the binary-compatibility variant of the elfloader) and the rootfs ships the `node` interpreter together with the shared libraries it links. With the loader and libraries present, the dynamic executable boots.
73+
74+
A plain `docker build` OCI image will NOT boot on the runtime. The image must be in the Unikraft Cloud format produced by `kraft`. The `Kraftfile` and `kraft cloud deploy` command handle this packaging.
75+
76+
### Write the Dockerfile
77+
78+
The build installs your dependencies in a regular `node` image, then assembles a minimal `FROM scratch` rootfs containing the interpreter, your app, and exactly the shared libraries `node` needs:
79+
80+
```dockerfile
81+
FROM node:22-alpine AS build
82+
WORKDIR /usr/src
83+
COPY package*.json ./
84+
RUN npm install
85+
# npm install with zero deps creates no node_modules dir; ensure it exists so the
86+
# COPY below succeeds and adding deps later needs no Dockerfile change.
87+
RUN mkdir -p node_modules
88+
COPY app.js ./
89+
# Record node's dynamic-library requirements in the build log for auditing.
90+
RUN echo "=== ldd node ===" && ldd /usr/local/bin/node || true
91+
92+
FROM scratch
93+
# The node interpreter.
94+
COPY --from=build /usr/local/bin/node /usr/bin/node
95+
# musl dynamic loader + libc (ld-musl is both loader and libc on alpine).
96+
COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
97+
# C++/GCC runtime libraries node links against.
98+
COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
99+
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
100+
COPY --from=build /etc/os-release /etc/os-release
101+
# Application + dependency tree.
102+
COPY --from=build /usr/src/node_modules /usr/src/node_modules
103+
COPY --from=build /usr/src/app.js /usr/src/server.js
104+
```
105+
106+
> **Note:** the scratch image has no package manager, so every shared library `node` links must be copied explicitly — a missing `.so` makes the instance fail to boot. The `ldd /usr/local/bin/node` line in the build log shows exactly which libraries are needed; for stock `node:22-alpine` the three above are the full set.
107+
108+
### Write the Kraftfile
109+
110+
```yaml
111+
spec: v0.7
112+
113+
name: hello-node
114+
115+
runtime: base-compat:latest
116+
117+
rootfs:
118+
source: ./Dockerfile
119+
format: erofs
120+
121+
cmd: ["/usr/bin/node", "/usr/src/server.js"]
122+
```
123+
124+
`runtime: base-compat:latest` is the binary-compatibility elfloader runtime that loads the dynamic `node` executable. `rootfs.source: ./Dockerfile` tells `kraft` to build the rootfs from your Dockerfile.
125+
126+
### Start a BuildKit daemon
127+
128+
`kraft` uses BuildKit to build the rootfs. Start one if you don't already have one running:
129+
130+
```sh
131+
docker run -d --name buildkit --privileged moby/buildkit:latest
132+
```
133+
134+
### Build and publish with `kraft cloud deploy --no-start`
135+
136+
Use `kraft` only to build and publish the image — you deploy the running workload with `datumctl compute` in the next step. The `--no-start` (`-S`) flag builds the unikernel package and pushes it to the metro registry **without** starting an instance. It pushes to `index.unikraft.io/datum/<name>`. The `-M` flag sets the memory allocation in MiB and is required.
137+
138+
```sh
139+
export KRAFTKIT_NO_CHECK_UPDATES=true
140+
141+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
142+
--buildkit-host docker-container://buildkit \
143+
deploy --no-start -M 512 --name hello-node \
144+
--runtime base-compat:latest --rootfs ./Dockerfile .
145+
```
146+
147+
After this command completes, your image is available at `index.unikraft.io/datum/hello-node:latest`, ready for Datum compute to deploy.
148+
149+
---
150+
151+
## 3. Deploy on Datum compute
152+
153+
You have two options: a manifest file (recommended for repeatability) or flags.
154+
155+
### Option A — manifest file (recommended)
156+
157+
Create `workload.yaml`:
158+
159+
```yaml
160+
apiVersion: compute.datumapis.com/v1alpha
161+
kind: Workload
162+
metadata:
163+
name: hello-node
164+
labels:
165+
app: hello-node
166+
spec:
167+
template:
168+
metadata:
169+
labels:
170+
app: hello-node
171+
spec:
172+
runtime:
173+
resources:
174+
instanceType: datumcloud/d1-standard-2
175+
sandbox:
176+
containers:
177+
- name: app
178+
image: index.unikraft.io/datum/hello-node:latest
179+
ports:
180+
- name: http
181+
port: 8080
182+
protocol: TCP
183+
networkInterfaces:
184+
- network:
185+
name: default
186+
placements:
187+
- name: default
188+
cityCodes:
189+
- DFW
190+
scaleSettings:
191+
minReplicas: 1
192+
instanceManagementPolicy: OrderedReady
193+
```
194+
195+
Deploy it:
196+
197+
```sh
198+
datumctl compute deploy -f workload.yaml -y
199+
```
200+
201+
### Option B — flags
202+
203+
```sh
204+
datumctl compute deploy hello-node \
205+
--image=index.unikraft.io/datum/hello-node:latest \
206+
--city=DFW \
207+
--port=8080 \
208+
--min=1
209+
```
210+
211+
Both forms create (or update) the workload. The `--city` flag accepts one or more city codes; `DFW` targets the US Central region.
212+
213+
---
214+
215+
## 4. Verify the instance is running
216+
217+
List instances and watch for the status to reach `Running`:
218+
219+
```sh
220+
datumctl compute instances --workload=hello-node
221+
```
222+
223+
A healthy instance shows `Ready: true` and `Running`. The `EXTERNAL IP` column is populated once the instance is live.
224+
225+
For a detailed view of a single instance, including conditions and any failure reason:
226+
227+
```sh
228+
datumctl compute instances describe <instance-name>
229+
```
230+
231+
Once the instance is `Running`, curl the external endpoint. UKC fronts the service with TLS on port 443 and redirects plain HTTP on port 80:
232+
233+
```sh
234+
# Get the external IP or hostname from the instance list, then:
235+
curl https://<EXTERNAL-IP>/
236+
# -> Hello from Datum (Node)
237+
238+
curl https://<EXTERNAL-IP>/healthz
239+
# -> ok
240+
```
241+
242+
Use `-k` if the TLS certificate is self-signed in your metro:
243+
244+
```sh
245+
curl -k https://<EXTERNAL-IP>/
246+
```
247+
248+
---
249+
250+
## 5. Update the workload
251+
252+
To deploy a new version, rebuild and publish the image (repeating step 2), then redeploy. Using the manifest:
253+
254+
```sh
255+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
256+
--buildkit-host docker-container://buildkit \
257+
deploy --no-start -M 512 --name hello-node \
258+
--runtime base-compat:latest --rootfs ./Dockerfile .
259+
260+
datumctl compute deploy -f workload.yaml -y
261+
```
262+
263+
Or with flags:
264+
265+
```sh
266+
datumctl compute deploy hello-node \
267+
--image=index.unikraft.io/datum/hello-node:latest \
268+
--city=DFW \
269+
--port=8080
270+
```
271+
272+
Watch the rollout progress:
273+
274+
```sh
275+
datumctl compute rollout hello-node
276+
```
277+
278+
---
279+
280+
## 6. Clean up
281+
282+
```sh
283+
# Delete the workload and all its instances.
284+
datumctl compute destroy hello-node -y
285+
286+
# Stop the local BuildKit daemon.
287+
docker rm -f buildkit
288+
```
289+
290+
---
291+
292+
## Troubleshooting
293+
294+
### The image fails to boot: missing shared library
295+
296+
If the unikernel console shows a library-not-found error at boot, the rootfs is missing a shared library that `node` (or one of your dependencies) needs. The scratch image has no package manager, so every `.so` must be copied in explicitly. Check:
297+
298+
- The `ldd /usr/local/bin/node` output in the build log lists the libraries `node` itself needs — confirm each is copied into the scratch stage.
299+
- If you added a **native (node-gyp) addon**, it compiles to additional `.so` files with their own library dependencies. Run `ldd` over the addon's `.node`/`.so` files and copy any libraries they pull in. Pure-JS dependencies need nothing extra.
300+
- The image was built with `kraft cloud deploy`, not plain `docker build`.
301+
302+
### Instance shows `Ready` but the endpoint doesn't respond
303+
304+
If an instance reports `Ready` but a `curl` to its endpoint hangs or fails, the unikernel may not have booted cleanly. The unikernel console is the source of truth — read it directly:
305+
306+
```sh
307+
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
308+
instance logs <ukc-instance-name>
309+
```
310+
311+
A healthy boot prints your `listening on :8080` line. A library-not-found error means a `.so` is missing from the rootfs (see above). The `<ukc-instance-name>` appears in the instance's details from `datumctl compute instances describe <instance-name>`.
312+
313+
### Image pull failures on the instance
314+
315+
`datumctl compute instances describe <instance-name>` reports a condition with reason `ImageUnavailable` when the platform cannot pull the image. Confirm:
316+
317+
- The image was pushed to `index.unikraft.io/datum/<name>` (the metro registry), not to an external container registry like GHCR or Docker Hub. The platform pulls from the UKC metro registry.
318+
- The `kraft cloud deploy` command completed without errors and printed the image reference.
319+
- The image name in `workload.yaml` matches exactly what `kraft cloud deploy` reported, including the `latest` tag.
320+
321+
### Instance is stuck and not progressing
322+
323+
```sh
324+
datumctl compute instances describe <instance-name>
325+
```
326+
327+
Look at the conditions in the output. Common states:
328+
329+
- `QuotaGranted: False` — compute quota has not been provisioned for the project. Contact your platform operator.
330+
- `Programmed: False` — the instance has not been scheduled to a node yet. This is normal for a few seconds after deploy; if it persists, check that the city code in your workload matches an available location.
331+
- `Ready: False, reason: SchedulingGatesPresent` — a scheduling prerequisite (such as a network) has not been satisfied. Confirm your project has a `default` Network resource provisioned.

examples/hello-node/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Build artifacts produced locally; not committed.
2+
/node_modules/
3+
.unikraft/

examples/hello-node/Dockerfile

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Multi-stage build for the Node.js runtime proof.
2+
#
3+
# Unlike the Go/Rust proofs, Node is NOT a static-PIE binary -- it is a dynamic
4+
# musl ELF that needs the musl loader and a handful of shared libraries at boot.
5+
# So this rootfs ships the `node` interpreter AND every shared library it links
6+
# against, and the unikernel runs on the base-compat:latest runtime (the
7+
# binary-compatibility / dynamic-loader elfloader variant), NOT base:latest.
8+
#
9+
# Stage 1 (node:22-alpine) installs deps and prints `ldd node` so the build log
10+
# records exactly which shared objects the rootfs must carry. The scratch image
11+
# below has no package manager, so EVERY library node links must be copied
12+
# explicitly; a missing .so fails the instance at boot with library-not-found.
13+
FROM node:22-alpine AS build
14+
WORKDIR /usr/src
15+
COPY package*.json ./
16+
RUN npm install
17+
# npm install with zero deps creates no node_modules dir; ensure it exists so the
18+
# COPY below succeeds and adding deps later "just works" with no Dockerfile change.
19+
RUN mkdir -p node_modules
20+
COPY app.js ./
21+
# Record node's dynamic-library requirements in the build log for auditing.
22+
RUN echo "=== ldd /usr/local/bin/node ===" && ldd /usr/local/bin/node || true
23+
24+
FROM scratch
25+
# The node interpreter.
26+
COPY --from=build /usr/local/bin/node /usr/bin/node
27+
# musl dynamic loader + libc (ld-musl is both loader and libc on alpine).
28+
COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
29+
# C++/GCC runtime libraries node links against.
30+
COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
31+
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
32+
# os-release lets node/libc identify the platform cleanly.
33+
COPY --from=build /etc/os-release /etc/os-release
34+
# Application + (empty for the hello case) dependency tree.
35+
COPY --from=build /usr/src/node_modules /usr/src/node_modules
36+
COPY --from=build /usr/src/app.js /usr/src/server.js

examples/hello-node/Kraftfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Kraftfile for the Node.js runtime proof.
2+
#
3+
# Node is a dynamic musl ELF, so it runs on base-compat:latest (the
4+
# binary-compatibility / dynamic-loader elfloader variant), NOT base:latest used
5+
# by the static-PIE Go/Rust proofs. The rootfs (built from the multi-stage
6+
# Dockerfile) carries the node interpreter plus every shared library it links.
7+
#
8+
# Build/push (push-only, do not start):
9+
# kraft cloud --metro <metro>/v1 --token <token> \
10+
# --buildkit-host docker-container://buildkit \
11+
# deploy --no-start -M 512 --name hello-node \
12+
# --runtime base-compat:latest --rootfs ./Dockerfile .
13+
spec: v0.7
14+
15+
name: hello-node
16+
17+
runtime: base-compat:latest
18+
19+
rootfs:
20+
source: ./Dockerfile
21+
format: erofs
22+
23+
cmd: ["/usr/bin/node", "/usr/src/server.js"]

0 commit comments

Comments
 (0)