Running a Node.js Microservice on Local Kyma with BTP Service Operator

This post picks up where the Python microservice post left off. If you haven’t read that one, the short version is: SAP has withdrawn Kyma runtime from BTP trial, so we built a local Kyma environment on k3d instead. That gave us a working cluster with Istio, API Gateway, and a deployed Python Flask app accessible over HTTP and HTTPS.

This post does two things. First, it connects that local cluster to SAP BTP trial using the BTP Service Operator — so you can provision real BTP services from your local environment. Second, it deploys a Node.js microservice through the same stack, partly to show the pattern works across runtimes, and partly because doing it twice cements how the pieces fit together.

Part 1 — Connecting to SAP BTP Trial

Why Bother?

The local Kyma cluster works fine in isolation, but the point of Kyma in the SAP context is the integration with BTP services — Destination Service, XSUAA, Connectivity, and so on. The BTP Service Operator is what bridges the gap. It lets you create ServiceInstance and ServiceBinding resources on your local cluster that provision real services on BTP, the same way a managed Kyma runtime would.

Your BTP trial account still works — it’s just the managed Kyma runtime that’s been withdrawn. The service marketplace is still there.

Prerequisites

You’ll need:

The local Kyma cluster from the previous post (k3d, Istio, API Gateway, BTP Manager installed)BTP CLI (btp) installed and logged inHelm — brew install helm on macOS, or see helm.sh for other platformskubectl pointed at your local cluster

Step 1 — Create a Service Manager Instance

Set your BTP CLI target to your subaccount:

btp target –subaccount <your-subaccount-id>

Then create a Service Manager instance with the service-operator-access plan:

btp create services/instance
–subaccount <your-subaccount-id>
–offering-name service-manager
–plan-name service-operator-access
–name btp-operator-access

This is what gives the BTP Service Operator permission to talk to the BTP service marketplace on your behalf.

Step 2 — Create a Service Binding and Extract Credentials

Create a binding for the instance:

btp create services/binding
–subaccount <your-subaccount-id>
–instance-name btp-operator-access
–name btp-operator-key

Get the binding ID:

btp get services/binding
–subaccount <your-subaccount-id>
–name btp-operator-key

Extract the credentials:

btp get services/binding –id <binding-id>

You’re looking for four values: clientid, clientsecret, url, and tokenurl. You’ll need these in the next step.

Step 3 — Install cert-manager

The BTP Service Operator depends on cert-manager for webhook certificate management. Install it:

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

kubectl wait –for=condition=Available deployment –all -n cert-manager –timeout=90s

Step 4 — Install the BTP Service Operator via Helm

Add the SAP BTP operator Helm repo:

helm repo add sap-btp-operator https://sap.github.io/sap-btp-service-operator
helm repo update

Install the operator, passing credentials directly as Helm values:

helm install sap-btp-operator sap-btp-operator/sap-btp-operator
–namespace operators
–set manager.secret.clientid=”<clientid>”
–set manager.secret.clientsecret=”<clientsecret>”
–set manager.secret.sm_url=”<url>”
–set manager.secret.tokenurl=”<tokenurl>”

A note on passing credentials directly via –set rather than creating the secret first: the alternative approach (running the setup script and then installing Helm) can cause field manager conflicts where Helm and kubectl both think they own the secret. Passing them as Helm values at install time avoids that.

Verify it’s running:

kubectl get pods -n operators

You’re looking for the BTP operator pod in a Running state.

Part 2 — The Node.js Microservice

Why Node.js?

The Python post covered the core pattern — Dockerfile, Deployment, Service, APIRule. This post uses Node.js to show the same approach works across runtimes. The Kubernetes side is identical; only the application code and Dockerfile change.

The Application

app.js

const express = require(‘express’);
const app = express();
const port = process.env.PORT || 8080;

app.get(‘/health’, (req, res) => {
res.json({ status: ‘ok’ });
});

app.get(‘/api/data’, (req, res) => {
res.json({
message: ‘Hello from Kyma!’,
environment: process.env.ENVIRONMENT || ‘dev’
});
});

app.listen(port, ‘0.0.0.0’, () => {
console.log(`Server running on port ${port}`);
});

Initialise the project and install dependencies:

mkdir -p ~/Documents/GitHub/kubernetes-course/kyma/btp-node-app
cd ~/Documents/GitHub/kubernetes-course/kyma/btp-node-app
npm init -y
npm install express

The Dockerfile

FROM node:20-slim

WORKDIR /app

COPY package*.json .
RUN npm install –production

COPY app.js .

EXPOSE 8080

CMD [“node”, “app.js”]

Build and Test Locally

docker build –no-cache -t btp-node-app:v1 .

The –no-cache flag matters here. If you’ve run npm install in the project folder before adding express to package.json, Docker may have a cached layer from that earlier run. When it rebuilds, it sees the npm install step hasn’t changed and uses the cache — which means express never gets installed in the image. The –no-cache flag forces every layer to rebuild from scratch.

This is the kind of thing that produces a perfectly healthy-looking container that crashes immediately on startup with a module not found error. Worth knowing.

Test it before touching Kubernetes:

docker run –rm -p 8081:8080 btp-node-app:v1
curl http://localhost:8081/health
curl http://localhost:8081/api/data

If both return JSON, the app is fine. Stop the container.

Import into k3d

k3d image import btp-node-app:v1 -c kyma

Kubernetes Manifests

Create a k8s/ folder in the project directory.

k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
name: btp-node-app
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: btp-node-app
template:
metadata:
labels:
app: btp-node-app
spec:
containers:
– name: btp-node-app
image: btp-node-app:v1
imagePullPolicy: Never
ports:
– containerPort: 8080
env:
– name: ENVIRONMENT
value: “kyma-local”
resources:
requests:
memory: “64Mi”
cpu: “100m”
limits:
memory: “128Mi”
cpu: “200m”

k8s/service.yaml

apiVersion: v1
kind: Service
metadata:
name: btp-node-app
namespace: default
spec:
selector:
app: btp-node-app
ports:
– port: 80
targetPort: 8080

k8s/apirule.yaml

apiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: btp-node-app
namespace: default
spec:
gateway: kyma-system/kyma-gateway
hosts:
– btp-node-app.local.kyma.dev
service:
name: btp-node-app
port: 80
rules:
– path: /*
methods: [“GET”, “POST”]
noAuth: true

Deploy

kubectl apply -f k8s/

Add the hosts entry:

echo “127.0.0.1 btp-node-app.local.kyma.dev” | sudo tee -a /etc/hosts

Check the pod and APIRule:

kubectl get pods -l app=btp-node-app
kubectl get apirule btp-node-app

Pod should be 2/2 (container plus Istio sidecar). APIRule should be Ready.

Test

HTTP:

curl -H “Host: btp-node-app.local.kyma.dev” http://127.0.0.1:30080/health
curl -H “Host: btp-node-app.local.kyma.dev” http://127.0.0.1:30080/api/data

HTTPS:

curl -k –resolve btp-node-app.local.kyma.dev:30443:127.0.0.1
-H “Host: btp-node-app.local.kyma.dev”
https://btp-node-app.local.kyma.dev:30443/health

curl -k –resolve btp-node-app.local.kyma.dev:30443:127.0.0.1
-H “Host: btp-node-app.local.kyma.dev”
https://btp-node-app.local.kyma.dev:30443/api/data

Expected:

{“status”: “ok”}
{“environment”: “kyma-local”, “message”: “Hello from Kyma!”}

 

At this point you have a local Kyma cluster that:

Runs containerised microservices in multiple runtimesExposes them externally via Istio and APIRule v2Is connected to SAP BTP trial via the BTP Service Operator

The next logical step is creating a ServiceInstance and ServiceBinding on the local cluster to provision a real BTP service — Destination Service is the obvious one — and consuming it from a microservice. That’s where the SAP-specific integration story starts to get interesting.

That’ll be the next post.

 

​ This post picks up where the Python microservice post left off. If you haven’t read that one, the short version is: SAP has withdrawn Kyma runtime from BTP trial, so we built a local Kyma environment on k3d instead. That gave us a working cluster with Istio, API Gateway, and a deployed Python Flask app accessible over HTTP and HTTPS.This post does two things. First, it connects that local cluster to SAP BTP trial using the BTP Service Operator — so you can provision real BTP services from your local environment. Second, it deploys a Node.js microservice through the same stack, partly to show the pattern works across runtimes, and partly because doing it twice cements how the pieces fit together.Part 1 — Connecting to SAP BTP TrialWhy Bother?The local Kyma cluster works fine in isolation, but the point of Kyma in the SAP context is the integration with BTP services — Destination Service, XSUAA, Connectivity, and so on. The BTP Service Operator is what bridges the gap. It lets you create ServiceInstance and ServiceBinding resources on your local cluster that provision real services on BTP, the same way a managed Kyma runtime would.Your BTP trial account still works — it’s just the managed Kyma runtime that’s been withdrawn. The service marketplace is still there.PrerequisitesYou’ll need:The local Kyma cluster from the previous post (k3d, Istio, API Gateway, BTP Manager installed)BTP CLI (btp) installed and logged inHelm — brew install helm on macOS, or see helm.sh for other platformskubectl pointed at your local clusterStep 1 — Create a Service Manager InstanceSet your BTP CLI target to your subaccount:btp target –subaccount <your-subaccount-id>Then create a Service Manager instance with the service-operator-access plan:btp create services/instance
–subaccount <your-subaccount-id>
–offering-name service-manager
–plan-name service-operator-access
–name btp-operator-accessThis is what gives the BTP Service Operator permission to talk to the BTP service marketplace on your behalf.Step 2 — Create a Service Binding and Extract CredentialsCreate a binding for the instance:btp create services/binding
–subaccount <your-subaccount-id>
–instance-name btp-operator-access
–name btp-operator-keyGet the binding ID:btp get services/binding
–subaccount <your-subaccount-id>
–name btp-operator-keyExtract the credentials:btp get services/binding –id <binding-id>You’re looking for four values: clientid, clientsecret, url, and tokenurl. You’ll need these in the next step.Step 3 — Install cert-managerThe BTP Service Operator depends on cert-manager for webhook certificate management. Install it:kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

kubectl wait –for=condition=Available deployment –all -n cert-manager –timeout=90sStep 4 — Install the BTP Service Operator via HelmAdd the SAP BTP operator Helm repo:helm repo add sap-btp-operator https://sap.github.io/sap-btp-service-operator
helm repo updateInstall the operator, passing credentials directly as Helm values:helm install sap-btp-operator sap-btp-operator/sap-btp-operator
–namespace operators
–set manager.secret.clientid=”<clientid>”
–set manager.secret.clientsecret=”<clientsecret>”
–set manager.secret.sm_url=”<url>”
–set manager.secret.tokenurl=”<tokenurl>”A note on passing credentials directly via –set rather than creating the secret first: the alternative approach (running the setup script and then installing Helm) can cause field manager conflicts where Helm and kubectl both think they own the secret. Passing them as Helm values at install time avoids that.Verify it’s running:kubectl get pods -n operatorsYou’re looking for the BTP operator pod in a Running state.Part 2 — The Node.js MicroserviceWhy Node.js?The Python post covered the core pattern — Dockerfile, Deployment, Service, APIRule. This post uses Node.js to show the same approach works across runtimes. The Kubernetes side is identical; only the application code and Dockerfile change.The Applicationapp.jsconst express = require(‘express’);
const app = express();
const port = process.env.PORT || 8080;

app.get(‘/health’, (req, res) => {
res.json({ status: ‘ok’ });
});

app.get(‘/api/data’, (req, res) => {
res.json({
message: ‘Hello from Kyma!’,
environment: process.env.ENVIRONMENT || ‘dev’
});
});

app.listen(port, ‘0.0.0.0’, () => {
console.log(`Server running on port ${port}`);
});Initialise the project and install dependencies:mkdir -p ~/Documents/GitHub/kubernetes-course/kyma/btp-node-app
cd ~/Documents/GitHub/kubernetes-course/kyma/btp-node-app
npm init -y
npm install expressThe DockerfileFROM node:20-slim

WORKDIR /app

COPY package*.json .
RUN npm install –production

COPY app.js .

EXPOSE 8080

CMD [“node”, “app.js”]Build and Test Locallydocker build –no-cache -t btp-node-app:v1 .The –no-cache flag matters here. If you’ve run npm install in the project folder before adding express to package.json, Docker may have a cached layer from that earlier run. When it rebuilds, it sees the npm install step hasn’t changed and uses the cache — which means express never gets installed in the image. The –no-cache flag forces every layer to rebuild from scratch.This is the kind of thing that produces a perfectly healthy-looking container that crashes immediately on startup with a module not found error. Worth knowing.Test it before touching Kubernetes:docker run –rm -p 8081:8080 btp-node-app:v1
curl http://localhost:8081/health
curl http://localhost:8081/api/dataIf both return JSON, the app is fine. Stop the container.Import into k3dk3d image import btp-node-app:v1 -c kymaKubernetes ManifestsCreate a k8s/ folder in the project directory.k8s/deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: btp-node-app
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: btp-node-app
template:
metadata:
labels:
app: btp-node-app
spec:
containers:
– name: btp-node-app
image: btp-node-app:v1
imagePullPolicy: Never
ports:
– containerPort: 8080
env:
– name: ENVIRONMENT
value: “kyma-local”
resources:
requests:
memory: “64Mi”
cpu: “100m”
limits:
memory: “128Mi”
cpu: “200m”k8s/service.yamlapiVersion: v1
kind: Service
metadata:
name: btp-node-app
namespace: default
spec:
selector:
app: btp-node-app
ports:
– port: 80
targetPort: 8080k8s/apirule.yamlapiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: btp-node-app
namespace: default
spec:
gateway: kyma-system/kyma-gateway
hosts:
– btp-node-app.local.kyma.dev
service:
name: btp-node-app
port: 80
rules:
– path: /*
methods: [“GET”, “POST”]
noAuth: trueDeploykubectl apply -f k8s/Add the hosts entry:echo “127.0.0.1 btp-node-app.local.kyma.dev” | sudo tee -a /etc/hostsCheck the pod and APIRule:kubectl get pods -l app=btp-node-app
kubectl get apirule btp-node-appPod should be 2/2 (container plus Istio sidecar). APIRule should be Ready.TestHTTP:curl -H “Host: btp-node-app.local.kyma.dev” http://127.0.0.1:30080/health
curl -H “Host: btp-node-app.local.kyma.dev” http://127.0.0.1:30080/api/dataHTTPS:curl -k –resolve btp-node-app.local.kyma.dev:30443:127.0.0.1
-H “Host: btp-node-app.local.kyma.dev”
https://btp-node-app.local.kyma.dev:30443/health

curl -k –resolve btp-node-app.local.kyma.dev:30443:127.0.0.1
-H “Host: btp-node-app.local.kyma.dev”
https://btp-node-app.local.kyma.dev:30443/api/dataExpected:{“status”: “ok”}
{“environment”: “kyma-local”, “message”: “Hello from Kyma!”} At this point you have a local Kyma cluster that:Runs containerised microservices in multiple runtimesExposes them externally via Istio and APIRule v2Is connected to SAP BTP trial via the BTP Service OperatorThe next logical step is creating a ServiceInstance and ServiceBinding on the local cluster to provision a real BTP service — Destination Service is the obvious one — and consuming it from a microservice. That’s where the SAP-specific integration story starts to get interesting.That’ll be the next post.   Read More Technology Blog Posts by Members articles 

#SAP

#SAPTechnologyblog

You May Also Like

More From Author