Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 331 additions & 0 deletions docs/guides/deploy-a-node-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
# Deploy a Node.js Web Service on Datum Compute

> Last verified: 2026-06-02 against the `hello-node` example and the live `kraft` / `datumctl compute` CLIs.
> The complete, ready-to-deploy example for this guide lives in [`examples/hello-node/`](../../examples/hello-node/).

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:

- A Node.js application packaged as a Unikraft unikernel image
- The image published to the Unikraft Cloud metro registry
- A running workload deployed with `datumctl compute deploy`
- A verified HTTP response from your instance

**What you need before starting:**

- `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.
- `datumctl` installed with the compute plugin, authenticated to your Datum Cloud project.
- Docker (with BuildKit) running locally.
- Node.js (for local development only — the build happens inside Docker).

---

## 1. Write the application

Create a project directory and add two files.

**`app.js`**

```js
const http = require('http');

const port = parseInt(process.env.PORT, 10) || 8080;

const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok\n');
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from Datum (Node)\n');
});

server.listen(port, () => {
console.log('listening on :' + port);
});
```

**`package.json`**

```json
{
"name": "hello-node",
"version": "1.0.0",
"private": true,
"main": "app.js",
"scripts": {
"start": "node app.js"
}
}
```

The service listens on `$PORT` (default `8080`), answers `/healthz` with `ok`, and has no external dependencies.

---

## 2. Build and publish the unikernel image with `kraft`

### Why Node runs on the `base-compat` runtime

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.

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.

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.

### Write the Dockerfile

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:

```dockerfile
FROM node:22-alpine AS build
WORKDIR /usr/src
COPY package*.json ./
RUN npm install
# npm install with zero deps creates no node_modules dir; ensure it exists so the
# COPY below succeeds and adding deps later needs no Dockerfile change.
RUN mkdir -p node_modules
COPY app.js ./
# Record node's dynamic-library requirements in the build log for auditing.
RUN echo "=== ldd node ===" && ldd /usr/local/bin/node || true

FROM scratch
# The node interpreter.
COPY --from=build /usr/local/bin/node /usr/bin/node
# musl dynamic loader + libc (ld-musl is both loader and libc on alpine).
COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
# C++/GCC runtime libraries node links against.
COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
COPY --from=build /etc/os-release /etc/os-release
# Application + dependency tree.
COPY --from=build /usr/src/node_modules /usr/src/node_modules
COPY --from=build /usr/src/app.js /usr/src/server.js
```

> **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.

### Write the Kraftfile

```yaml
spec: v0.7

name: hello-node

runtime: base-compat:latest

rootfs:
source: ./Dockerfile
format: erofs

cmd: ["/usr/bin/node", "/usr/src/server.js"]
```

`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.

### Start a BuildKit daemon

`kraft` uses BuildKit to build the rootfs. Start one if you don't already have one running:

```sh
docker run -d --name buildkit --privileged moby/buildkit:latest
```

### Build and publish with `kraft cloud deploy --no-start`

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.

```sh
export KRAFTKIT_NO_CHECK_UPDATES=true

kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
--buildkit-host docker-container://buildkit \
deploy --no-start -M 512 --name hello-node \
--runtime base-compat:latest --rootfs ./Dockerfile .
```

After this command completes, your image is available at `index.unikraft.io/datum/hello-node:latest`, ready for Datum compute to deploy.

---

## 3. Deploy on Datum compute

You have two options: a manifest file (recommended for repeatability) or flags.

### Option A — manifest file (recommended)

Create `workload.yaml`:

```yaml
apiVersion: compute.datumapis.com/v1alpha
kind: Workload
metadata:
name: hello-node
labels:
app: hello-node
spec:
template:
metadata:
labels:
app: hello-node
spec:
runtime:
resources:
instanceType: datumcloud/d1-standard-2
sandbox:
containers:
- name: app
image: index.unikraft.io/datum/hello-node:latest
ports:
- name: http
port: 8080
protocol: TCP
networkInterfaces:
- network:
name: default
placements:
- name: default
cityCodes:
- DFW
scaleSettings:
minReplicas: 1
instanceManagementPolicy: OrderedReady
```

Deploy it:

```sh
datumctl compute deploy -f workload.yaml -y
```

### Option B — flags

```sh
datumctl compute deploy hello-node \
--image=index.unikraft.io/datum/hello-node:latest \
--city=DFW \
--port=8080 \
--min=1
```

Both forms create (or update) the workload. The `--city` flag accepts one or more city codes; `DFW` targets the US Central region.

---

## 4. Verify the instance is running

List instances and watch for the status to reach `Running`:

```sh
datumctl compute instances --workload=hello-node
```

A healthy instance shows `Ready: true` and `Running`. The `EXTERNAL IP` column is populated once the instance is live.

For a detailed view of a single instance, including conditions and any failure reason:

```sh
datumctl compute instances describe <instance-name>
```

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:

```sh
# Get the external IP or hostname from the instance list, then:
curl https://<EXTERNAL-IP>/
# -> Hello from Datum (Node)

curl https://<EXTERNAL-IP>/healthz
# -> ok
```

Use `-k` if the TLS certificate is self-signed in your metro:

```sh
curl -k https://<EXTERNAL-IP>/
```

---

## 5. Update the workload

To deploy a new version, rebuild and publish the image (repeating step 2), then redeploy. Using the manifest:

```sh
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
--buildkit-host docker-container://buildkit \
deploy --no-start -M 512 --name hello-node \
--runtime base-compat:latest --rootfs ./Dockerfile .

datumctl compute deploy -f workload.yaml -y
```

Or with flags:

```sh
datumctl compute deploy hello-node \
--image=index.unikraft.io/datum/hello-node:latest \
--city=DFW \
--port=8080
```

Watch the rollout progress:

```sh
datumctl compute rollout hello-node
```

---

## 6. Clean up

```sh
# Delete the workload and all its instances.
datumctl compute destroy hello-node -y

# Stop the local BuildKit daemon.
docker rm -f buildkit
```

---

## Troubleshooting

### The image fails to boot: missing shared library

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:

- 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.
- 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.
- The image was built with `kraft cloud deploy`, not plain `docker build`.

### Instance shows `Ready` but the endpoint doesn't respond

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:

```sh
kraft cloud --metro "$UKC_METRO" --token "$UKC_TOKEN" \
instance logs <ukc-instance-name>
```

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>`.

### Image pull failures on the instance

`datumctl compute instances describe <instance-name>` reports a condition with reason `ImageUnavailable` when the platform cannot pull the image. Confirm:

- 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.
- The `kraft cloud deploy` command completed without errors and printed the image reference.
- The image name in `workload.yaml` matches exactly what `kraft cloud deploy` reported, including the `latest` tag.

### Instance is stuck and not progressing

```sh
datumctl compute instances describe <instance-name>
```

Look at the conditions in the output. Common states:

- `QuotaGranted: False` — compute quota has not been provisioned for the project. Contact your platform operator.
- `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.
- `Ready: False, reason: SchedulingGatesPresent` — a scheduling prerequisite (such as a network) has not been satisfied. Confirm your project has a `default` Network resource provisioned.
3 changes: 3 additions & 0 deletions examples/hello-node/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Build artifacts produced locally; not committed.
/node_modules/
.unikraft/
36 changes: 36 additions & 0 deletions examples/hello-node/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Multi-stage build for the Node.js runtime proof.
#
# Unlike the Go/Rust proofs, Node is NOT a static-PIE binary -- it is a dynamic
# musl ELF that needs the musl loader and a handful of shared libraries at boot.
# So this rootfs ships the `node` interpreter AND every shared library it links
# against, and the unikernel runs on the base-compat:latest runtime (the
# binary-compatibility / dynamic-loader elfloader variant), NOT base:latest.
#
# Stage 1 (node:22-alpine) installs deps and prints `ldd node` so the build log
# records exactly which shared objects the rootfs must carry. The scratch image
# below has no package manager, so EVERY library node links must be copied
# explicitly; a missing .so fails the instance at boot with library-not-found.
FROM node:22-alpine AS build
WORKDIR /usr/src
COPY package*.json ./
RUN npm install
# npm install with zero deps creates no node_modules dir; ensure it exists so the
# COPY below succeeds and adding deps later "just works" with no Dockerfile change.
RUN mkdir -p node_modules
COPY app.js ./
# Record node's dynamic-library requirements in the build log for auditing.
RUN echo "=== ldd /usr/local/bin/node ===" && ldd /usr/local/bin/node || true

FROM scratch
# The node interpreter.
COPY --from=build /usr/local/bin/node /usr/bin/node
# musl dynamic loader + libc (ld-musl is both loader and libc on alpine).
COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
# C++/GCC runtime libraries node links against.
COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
# os-release lets node/libc identify the platform cleanly.
COPY --from=build /etc/os-release /etc/os-release
# Application + (empty for the hello case) dependency tree.
COPY --from=build /usr/src/node_modules /usr/src/node_modules
COPY --from=build /usr/src/app.js /usr/src/server.js
23 changes: 23 additions & 0 deletions examples/hello-node/Kraftfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Kraftfile for the Node.js runtime proof.
#
# Node is a dynamic musl ELF, so it runs on base-compat:latest (the
# binary-compatibility / dynamic-loader elfloader variant), NOT base:latest used
# by the static-PIE Go/Rust proofs. The rootfs (built from the multi-stage
# Dockerfile) carries the node interpreter plus every shared library it links.
#
# Build/push (push-only, do not start):
# kraft cloud --metro <metro>/v1 --token <token> \
# --buildkit-host docker-container://buildkit \
# deploy --no-start -M 512 --name hello-node \
# --runtime base-compat:latest --rootfs ./Dockerfile .
spec: v0.7

name: hello-node

runtime: base-compat:latest

rootfs:
source: ./Dockerfile
format: erofs

cmd: ["/usr/bin/node", "/usr/src/server.js"]
Loading
Loading