[[{“value”:”
After many years of SAP projects, this is my very first blog post here 😃. I picked a topic that saves me time and nerves every day: running and deploying Fiori apps from my local VS Code against an on-premise S/4HANA system, through the same BTP destination and Cloud Connector that SAP Business Application Studio uses, without a dev space.
The whole trick fits in one command: cf ssh.
Why I stopped opening a dev space
Let me be clear first: SAP Business Application Studio is a great product. Generators, guided development, a ready-made toolchain in the browser. For many teams it is the right default.
But in daily life I kept hitting the same walls:
- Disk space. Dev spaces have a limited quota. A few
node_modulesfolders and an npm cache later, you are cleaning up instead of developing. - Dev spaces that don’t start, for no reason I could find. You wait, retry, and eventually create a new one.
npm run startthat hangs, again for no visible reason. Same project, same command, works fine locally.
Then I asked myself an honest question: why do I open BAS at all? My laptop already has VS Code, Node.js, the Fiori tools and my extensions. The only reason is that my laptop cannot reach the customer’s S/4HANA directly. Many customers only give access through remote desktops or VDIs. The only path from the outside world into the backend is BTP → Cloud Connector.
So the only things I really need from BTP are the destination and the connectivity to the Cloud Connector. Nothing else.
I also tried the SAP Business Application Studio desktop client for VS Code. It is a nice idea, but underneath it is still a dev space: same disk quota, same npm start that hangs, plus containers that sometimes hang on start.
So I went the other way: keep everything local and borrow only the connectivity from BTP, through cf ssh port forwarding.
The result in one table
Command What it does
npm run start-btp |
Starts the app on localhost:8081. Every /sap call goes through the BTP destination and the Cloud Connector. |
npm run deploy-btp |
Builds the app and deploys it to the ABAP repository (BSP), through the same destination. |
npm run deploy-btp-test |
Same as above in test mode. Nothing is written. |
Both commands are one-shot: they start the tunnel and the local proxy when needed, or reuse the ones already running. You never start anything by hand. You don’t need a BAS session or VPN tricks, and no BTP credentials are stored on your disk.
I use it for Fiori / UI5 development, but the same tunnel works for CAP or anything else that speaks HTTP to the backend (see the end of the post).
How it works
The key observation: destinations and the Cloud Connector are only reachable from inside BTP. More precisely, the connectivity proxy, the component that forwards your HTTP calls into the Cloud Connector tunnel, is only reachable from the Cloud Foundry container network. Your laptop can’t reach it, but any CF app container can.
A CF app that is bound to a destination and a connectivity service instance has everything needed in its environment (VCAP_SERVICES). If SSH is enabled for that app, cf ssh -L can forward a local port, through the container, to the connectivity proxy.
A small local Node.js proxy (btp-proxy, about 180 lines, no dependencies) does the rest:
- The browser talks to
fiori runonlocalhost:8081, as usual. fiori-tools-proxysends/saptobtp-proxyonlocalhost:5050instead of to a destination.btp-proxyadds what the connectivity proxy expects and sends the request into thecf sshtunnel.- Inside CF, the request reaches the connectivity proxy, then the Cloud Connector picks it up and calls the backend.
The CF app is only used as a jump host. Its code is never called, so any small app in your space will do (more on that in the prerequisites).
Under the hood: what happens at start-up
- Read the bindings.
cf app <app> --guid, thencf curl /v3/apps/<guid>/envreturns the app’sVCAP_SERVICES, using the CF session you already have fromcf login. The proxy keeps the destination and connectivity credentials in memory only. Nothing is written to disk, and no service key is created. - Get two tokens. An OAuth client-credentials call to XSUAA for each binding: one JWT for the Destination service, one for the connectivity proxy. Both are cached and refreshed one minute before they expire.
- Resolve the destination.
GET /destination-configuration/v1/destinations/<name>returns the virtual host URL, thesap-clientand theCloudConnectorLocationId. The proxy checks that it is anOnPremisedestination. - Open the tunnel.
cf ssh <app> -N -T -L 127.0.0.1:20003:<onpremise_proxy_host>:<onpremise_proxy_http_port>. Both values come from the connectivity binding. From now on, port 20003 on your laptop is the connectivity proxy. - Listen on
127.0.0.1:5050and printready.
Under the hood: one request
The connectivity proxy is a classic HTTP forward proxy. That explains the shape of the outgoing request:
- The request line carries the absolute URI of the destination’s virtual host (
GET http://s4h-virtual:443/sap/…), exactly like any browser behind a corporate proxy. Proxy-Authorization: Bearer <JWT>tells the connectivity proxy which subaccount you are.SAP-Connectivity-SCC-Location_IDselects the right Cloud Connector when several are connected to the subaccount.sap-clientis added from the destination when the request has none.- Your own
Authorizationheader (your SAP user) is passed through unchanged. The backend still authenticates you, exactly as in BAS.
On the way back, two small rewrites keep the browser happy on http://localhost: redirects to the virtual host become relative again, and the Secure flag is removed from cookies.
The authentication gotcha I hit with fiori deploy
In our landscape, through the Cloud Connector, the backend only accepted a user/password logon after a first 401 round trip that sets the cookies sap-usercontext and sap-ssolist. Browsers (and BAS) do this naturally. fiori deploy doesn’t: it sends basic auth straight away and ends with an “authentication error”, without any failed logon in SU01. That one cost me some time.
The proxy handles it. When a request carries Authorization but no sap-usercontext cookie, it first sends one anonymous request, collects those two cookies and adds them to the real request. After that, deploy works.
Prerequisites
- cf CLI v8, logged in and targeted at the space:
cf login -a https://api.cf.<region>.hana.ondemand.com -o <org> -s <space> --sso - Node.js 18+ (the scripts use the built-in
fetch). - The SpaceDeveloper role in that space. You need it to read app environments and to
cf ssh. - A CF app bound to a destination AND a connectivity service instance, with SSH enabled. Check it with
cf ssh-enabled <app>. If needed:cf allow-space-ssh <space>(space manager),cf enable-ssh <app>, thencf restart <app>. - The destination exists in the subaccount (Connectivity → Destinations), proxy type
OnPremise, for exampleS4H_CLNT100.
Installing the cf CLI
Already installed? cf version prints cf version 8.x. Otherwise:
 Windows macOS
| Package manager | winget install CloudFoundry.CLI.v8or choco install cloudfoundry-cli |
brew install cloudfoundry/tap/cf-cli@8 |
| Installer | Download the installer from the official installation guide |
No suitable app in your space? Push a tiny one
The app is only a jump host, so it can be as small as CF allows. Create the two service instances (entitlements needed), push an app that does nothing, and bind both:
cf create-service destination lite my-destination
cf create-service connectivity lite my-connectivity
# manifest.yml - push from an empty folder containing any small file
applications:
- name: my-tunnel-app
memory: 64M
disk_quota: 256M
instances: 1
buildpacks: [binary_buildpack]
command: sleep infinity
no-route: true
health-check-type: process
services:
- my-destination
- my-connectivity
Then cf push and cf enable-ssh my-tunnel-app, followed by cf restart my-tunnel-app. The destinations themselves stay at subaccount level: any destination service instance in the subaccount can read them.
Add it to your project
1. Copy the two scripts
File Role
scripts/btp-proxy.js |
The proxy: reads the destination, opens the cf ssh tunnel, forwards requests |
scripts/btp.js |
The one-shot start / deploy commands |
.vscode/launch.json |
Optional. Adds “Run with BTP destination (cf ssh)” to F5 |
Both scripts only use Node.js built-ins, so there is nothing to install. Save them in a scripts/ folder of your project.
scripts/btp-proxy.js
#!/usr/bin/env node
// Local HTTP proxy to an on-premise system, through a BTP destination.
// Reads the destination + connectivity bindings of a CF app, opens a cf ssh
// tunnel to the connectivity proxy and forwards every request through it.
const http = require('http');
const net = require('net');
const { spawn, execFileSync } = require('child_process');
const APP = process.env.BTP_APP || 'my-tunnel-app';
const DESTINATION = process.env.BTP_DESTINATION || 'S4H_CLNT100';
const PROXY_PORT = Number(process.env.BTP_PROXY_PORT || 5050);
const TUNNEL_PORT = Number(process.env.BTP_TUNNEL_PORT || 20003);
const DEBUG = process.env.BTP_PROXY_DEBUG === '1';
const BASIC = process.env.BTP_PROXY_USER
? 'Basic ' + Buffer.from(`${process.env.BTP_PROXY_USER}:${process.env.BTP_PROXY_PWD}`).toString('base64')
: null;
const PRIME_COOKIES = ['sap-usercontext', 'sap-ssolist'];
const log = (...a) => console.log('[btp-proxy]', ...a);
const cf = (args) => execFileSync('cf', args, { encoding: 'utf8' });
// 1. Service bindings of the CF app: read in memory, never written to disk
function readBindings() {
const guid = cf(['app', APP, '--guid']).trim();
const env = JSON.parse(cf(['curl', `/v3/apps/${guid}/env`]));
const vcap = env.system_env_json.VCAP_SERVICES || {};
const dest = vcap.destination?.[0]?.credentials;
const conn = vcap.connectivity?.[0]?.credentials;
if (!dest || !conn) {
throw new Error(`App ${APP} must be bound to a destination and a connectivity service`);
}
return { dest, conn };
}
// 2. OAuth client-credentials tokens, cached until one minute before expiry
const tokens = new Map();
async function token(creds) {
const hit = tokens.get(creds.clientid);
if (hit && hit.exp > Date.now() + 60_000) return hit.value;
const res = await fetch(`${creds.url}/oauth/token`, {
method: 'POST',
headers: {
Authorization: 'Basic ' + Buffer.from(`${creds.clientid}:${creds.clientsecret}`).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
if (!res.ok) throw new Error(`Token request to ${creds.url} failed: ${res.status}`);
const json = await res.json();
tokens.set(creds.clientid, { value: json.access_token, exp: Date.now() + json.expires_in * 1000 });
return json.access_token;
}
// 3. Destination lookup: virtual host, sap-client and Cloud Connector location
async function readDestination(dest) {
const url = `${dest.uri}/destination-configuration/v1/destinations/${encodeURIComponent(DESTINATION)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${await token(dest)}` } });
if (!res.ok) throw new Error(`Destination ${DESTINATION} not found (${res.status})`);
const { destinationConfiguration: c } = await res.json();
if (c.ProxyType !== 'OnPremise') throw new Error(`Destination ${DESTINATION} is not an OnPremise destination`);
return { origin: new URL(c.URL).origin, client: c['sap-client'], location: c.CloudConnectorLocationId };
}
// 4. cf ssh port forward: localhost:TUNNEL_PORT -> connectivity proxy inside CF
function openTunnel(conn) {
const forward = `127.0.0.1:${TUNNEL_PORT}:${conn.onpremise_proxy_host}:${conn.onpremise_proxy_http_port}`;
const ssh = spawn('cf', ['ssh', APP, '-N', '-T', '-L', forward], { stdio: ['ignore', 'inherit', 'inherit'] });
ssh.on('exit', (code) => { log(`tunnel closed (${code})`); process.exit(1); });
return ssh;
}
function waitForPort(port, timeoutMs = 30_000) {
const until = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
(function attempt() {
const s = net.connect(port, '127.0.0.1', () => { s.end(); resolve(); });
s.on('error', () => Date.now() > until
? reject(new Error(`tunnel not reachable on port ${port}`))
: setTimeout(attempt, 500));
})();
});
}
function portInUse(port) {
return new Promise((resolve) => {
const s = net.connect(port, '127.0.0.1', () => { s.end(); resolve(true); });
s.on('error', () => resolve(false));
});
}
// One request through the tunnel: absolute URI + connectivity headers
function forward({ method, target, headers }, conn, location) {
return token(conn).then((jwt) => http.request({
host: '127.0.0.1',
port: TUNNEL_PORT,
method,
path: target.href,
headers: {
...headers,
host: target.host,
'proxy-authorization': `Bearer ${jwt}`,
...(location && { 'sap-connectivity-scc-location_id': location })
}
}));
}
// Some backends only accept a basic logon after a first 401 round trip that
// sets sap-usercontext / sap-ssolist. Browsers do that naturally, fiori deploy
// does not, so the proxy collects these cookies once and adds them itself.
let primed = null;
async function primeCookies(target, conn, location) {
if (primed) return primed;
const req = await forward({ method: 'GET', target, headers: {} }, conn, location);
primed = await new Promise((resolve, reject) => {
req.on('response', (res) => {
res.resume();
const cookies = (res.headers['set-cookie'] || [])
.map((c) => c.split(';')[0])
.filter((c) => PRIME_COOKIES.includes(c.split('=')[0]));
resolve(cookies);
});
req.on('error', reject);
req.end();
});
return primed;
}
async function main() {
if (await portInUse(TUNNEL_PORT)) {
throw new Error(`Port ${TUNNEL_PORT} is already in use - close the other tunnel or set BTP_TUNNEL_PORT`);
}
const { dest, conn } = readBindings();
const backend = await readDestination(dest);
log(`destination ${DESTINATION} -> ${backend.origin} (client ${backend.client || '-'}, location ${backend.location || 'default'})`);
const ssh = openTunnel(conn);
const stop = () => { ssh.removeAllListeners('exit'); ssh.kill(); process.exit(0); };
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
await waitForPort(TUNNEL_PORT);
http.createServer(async (req, res) => {
if (req.url === '/__btp-proxy/health') return res.end('ok');
if (req.url === '/__btp-proxy/shutdown' && req.method === 'POST') { res.end('bye'); return stop(); }
try {
const target = new URL(req.url, backend.origin);
if (backend.client && !target.searchParams.has('sap-client')) {
target.searchParams.set('sap-client', backend.client);
}
const headers = { ...req.headers };
delete headers['proxy-authorization'];
if (BASIC && !headers.authorization) headers.authorization = BASIC;
if (headers.authorization && !/sap-usercontext=/.test(headers.cookie || '')) {
const cookies = await primeCookies(target, conn, backend.location);
if (cookies.length) headers.cookie = [headers.cookie, ...cookies].filter(Boolean).join('; ');
}
const upstream = await forward({ method: req.method, target, headers }, conn, backend.location);
upstream.on('response', (up) => {
if (DEBUG) {
const names = (up.headers['set-cookie'] || []).map((c) => c.split('=')[0]);
log(req.method, req.url, up.statusCode, names.length ? `cookies: ${names.join(', ')}` : '');
}
// Keep the browser on localhost: relative redirects, no Secure flag over plain http
if (up.headers.location?.startsWith(backend.origin)) {
up.headers.location = up.headers.location.slice(backend.origin.length);
}
if (up.headers['set-cookie']) {
up.headers['set-cookie'] = up.headers['set-cookie'].map((c) => c.replace(/;s*secure/gi, ''));
}
res.writeHead(up.statusCode, up.headers);
up.pipe(res);
});
upstream.on('error', (e) => { res.writeHead(502); res.end(`btp-proxy: ${e.message}`); });
req.pipe(upstream);
} catch (e) {
res.writeHead(502);
res.end(`btp-proxy: ${e.message}`);
}
}).listen(PROXY_PORT, '127.0.0.1', () => log(`ready on http://127.0.0.1:${PROXY_PORT}`));
}
main().catch((e) => { console.error('[btp-proxy]', e.message); process.exit(1); });
scripts/btp.js
#!/usr/bin/env node
// One-shot commands: start the app or deploy it through btp-proxy.
// Reuses a proxy that is already running, otherwise starts one and stops it afterwards.
const path = require('path');
const { spawn } = require('child_process');
const PORT = Number(process.env.BTP_PROXY_PORT || 5050);
const PROXY = `http://127.0.0.1:${PORT}/__btp-proxy`;
const [command, ...flags] = process.argv.slice(2);
async function proxyRunning() {
try { return (await fetch(`${PROXY}/health`)).ok; } catch { return false; }
}
function startProxy() {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [path.join(__dirname, 'btp-proxy.js')], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
if (chunk.toString().includes('ready on')) resolve(child);
});
child.on('exit', () => reject(new Error('proxy exited during startup - check cf login / cf target')));
});
}
async function stopProxy() {
try { await fetch(`${PROXY}/shutdown`, { method: 'POST' }); } catch { /* already gone */ }
}
function run(cmd) {
return new Promise((resolve) => {
spawn(cmd, { stdio: 'inherit', shell: true }).on('exit', (code) => resolve(code ?? 1));
});
}
async function main() {
const ownProxy = !(await proxyRunning());
if (ownProxy) await startProxy();
else console.log(`[btp] reusing btp-proxy on port ${PORT}`);
process.on('SIGINT', async () => { if (ownProxy) await stopProxy(); process.exit(130); });
let code;
if (command === 'start') {
code = await run('npx fiori run --config ui5-btp.yaml --open "test/flp.html#app-preview"');
} else if (command === 'deploy') {
const test = flags.includes('--test') ? ' --testMode true' : '';
code = await run('npx ui5 build --config ui5-deploy-btp.yaml --clean-dest');
if (code === 0) code = await run(`npx dotenv -- fiori deploy --config ui5-deploy-btp.yaml --yes${test}`);
} else {
console.error('usage: node scripts/btp.js start | deploy [--test]');
code = 2;
}
if (ownProxy) await stopProxy();
process.exit(code);
}
main().catch((e) => { console.error('[btp]', e.message); process.exit(1); });
A detail that matters on Windows: btp.js stops the proxy with an HTTP call (POST /__btp-proxy/shutdown) instead of killing the process. On Windows, killing a Node process skips its exit handlers, and you would end up with an orphan cf ssh holding port 20003.
2. ui5-btp.yaml: run configuration
Copy your ui5.yaml to ui5-btp.yaml and point the backend to the proxy. Remove the destination: and client: lines: the proxy adds the client from the destination.
- name: fiori-tools-proxy
afterMiddleware: compression
configuration:
backend:
- path: /sap
url: http://127.0.0.1:5050 # btp-proxy
3. ui5-deploy-btp.yaml: deploy configuration
Copy your ui5-deploy.yaml to ui5-deploy-btp.yaml, set the target URL to the proxy and remove destination:. Keep your client, package and transport:
- name: deploy-to-abap
configuration:
target:
url: http://127.0.0.1:5050 # btp-proxy
client: "100"
credentials:
username: env:USR
password: env:SAP_PWD
app:
name: ZMY_APP
package: ZMY_PACKAGE
transport: S4HK900123
Keep the original ui5.yaml and ui5-deploy.yaml untouched, so BAS keeps working for colleagues who prefer it.
4. package.json
npm install --save-dev dotenv-cli
"scripts": {
"start-btp": "node scripts/btp.js start",
"deploy-btp": "node scripts/btp.js deploy",
"deploy-btp-test": "node scripts/btp.js deploy --test"
}
btp.js opens test/flp.html#app-preview. Adapt the fiori run line if your app starts elsewhere.
5. .env: deploy credentials
USR=<your SAP user>
SAP_PWD=<your password in the DEPLOY client>
.env must be in .gitignore. Variables set in your terminal take precedence over .env, so you can also skip the file and set them per session.
6. Optional: F5 in VS Code
{
"version": "0.2.0",
"configurations": [
{
"name": "Run with BTP destination (cf ssh)",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start-btp"],
"console": "integratedTerminal"
}
]
}
Daily use
I want to… Run Notes
| Run the app | npm run start-btp or F5 |
The browser opens localhost:8081. Log in with your SAP user of the destination’s client. Ctrl+C stops everything. |
| Try a deploy | npm run deploy-btp-test |
Test mode, nothing is written |
| Deploy | npm run deploy-btp |
Works whether the app is running in another terminal or not |
Configuration
All optional, as environment variables (terminal or .env😞
Variable Default Purpose
BTP_APP |
my-tunnel-app |
CF app whose destination + connectivity bindings are used (SSH enabled) |
BTP_DESTINATION |
S4H_CLNT100 |
Destination used by the proxy |
BTP_PROXY_PORT |
5050 |
Local proxy port |
BTP_TUNNEL_PORT |
20003 |
Local end of the cf ssh tunnel |
BTP_PROXY_USER / BTP_PROXY_PWD |
– | Log in automatically instead of the browser prompt |
BTP_PROXY_DEBUG |
– | 1 logs every request, its status and cookie names (never values) |
Switching backend or client is just BTP_DESTINATION=S4H_CLNT200 npm run start-btp.
Troubleshooting
Symptom Fix
proxy exited during startup |
Your CF session expired: cf login / cf target |
App … must be bound to a destination and a connectivity service |
Set BTP_APP to an app that has both bindings |
Port 20003 is already in use |
A tunnel from another tool is still open. Close it or set BTP_TUNNEL_PORT |
403 Access denied to resource … expose the resource in your cloud connector |
That URL path is not exposed in the Cloud Connector. Ask the Cloud Connector administrators |
| Deploy ends with an authentication error | Check USR / SAP_PWD. It must be the password of the deploy client (client: in ui5-deploy-btp.yaml) |
Token request … failed: 401 |
The binding uses X.509 credentials instead of a client secret. These scripts expect clientsecret. Rebind with a secret, or extend token() for mTLS |
What about security?
A fair question, and one to discuss with your BTP administrators before using this. My view:
- It doesn’t open anything new. Anyone who can
cf sshinto an app and read its environment can already do all of this by hand. The scripts need exactly the same SpaceDeveloper rights, and they only automate those steps. - Nothing is stored. The binding credentials and tokens live in memory for the lifetime of the proxy, with no service key and no file.
- The Cloud Connector stays in charge. Only the hosts and paths exposed in the Cloud Connector are reachable. Everything else gets a 403, exactly as from BAS.
- The backend still authenticates you with your own SAP user.
- Localhost only. The proxy listens on
127.0.0.1, so other machines on your network can’t use it. - It is a development tool. Use it on development and test subaccounts, not as a production integration path.
Not only for Fiori: CAP and friends
The proxy doesn’t know about Fiori at all. It is an HTTP endpoint on 127.0.0.1:5050 that ends up in your backend. For a CAP project consuming an on-premise OData service, point the remote service to it in a development profile and start the proxy with node scripts/btp-proxy.js:
"cds": {
"requires": {
"API_BUSINESS_PARTNER": {
"kind": "odata-v2",
"model": "srv/external/API_BUSINESS_PARTNER",
"[development]": {
"credentials": {
"url": "http://127.0.0.1:5050/sap/opu/odata/sap/API_BUSINESS_PARTNER"
}
}
}
}
}
Combined with BTP_PROXY_USER / BTP_PROXY_PWD, cds watch talks to the real backend from your laptop. The same goes for Postman, Bruno, curl or a quick test script.
Wrapping up
What I like most about this setup is that it removes a whole category of “why is it hanging today?” problems. My editor, my Node version, my disk and my extensions are all local and fast. BTP provides only the one thing my laptop can’t do on its own: a trusted path through the Cloud Connector.
Once again, BAS is a good tool and I still use it when it makes sense. But when the only reason to open it is connectivity, cf ssh is all you need.
This is my first post, so I would really appreciate your feedback. Tell me if this works in your landscape, if you hit other gotchas, or if you know a simpler way. See you in the comments!
“}]]Â
  Read More Technology Blog Posts by Members articlesÂ
#abap