Managing CAP Cloud Foundry Projects with Docker

In this post, we will explore how to manage CAP (Cloud Application Programming) projects using Docker. We know SAP CAP is the main framework for developing applications on SAP BTP, and we have used it in previous posts like How to build End-to-End custom applications in Cloud Foundry. In this post, we will focus on how to use Docker to streamline the development workflow of CAP projects and ensure consistency across different environments.

Start a new CAP project

Let's start a new CAP project adding some sample services and data.

Copy
# Create a new directory for the project
mkdir cap-docker-project
cd cap-docker-project
# Initialize a new CAP project
cds init
# Add a sample service
cds add tiny-sample
# Add sample data
cds add data
# Install dependencies
npm install

With that we can use the cds watch command to run the project locally, and we can access the service at http://localhost:4004/.

Using Docker to run the CAP project

Now that we have a CAP project, we can use Docker to run it in a containerized environment. This will allow us to ensure that the application runs consistently across different environments, and we can easily share the project with other developers.

Let's create a Dockerfile to define the image for our CAP project.

Copy
# ────────────────────────────────────────────────────
# Base image: Node.js LTS (Debian slim variant)
# ────────────────────────────────────────────────────
FROM node:lts-slim

# ────────────────────────────────────────────────────
# Metadata: author, version, description
# ────────────────────────────────────────────────────
LABEL author="Rafael Lopez" \
      version="1.0" \
      description="A simple CAP application"

# ────────────────────────────────────────────────────
# Install the SAP Cloud Application Programming Model (CAP) CLI tools
# ────────────────────────────────────────────────────
RUN npm i -g @sap/cds-dk

# ────────────────────────────────────────────────────
# Set the working directory for your application code
# ────────────────────────────────────────────────────
WORKDIR /app

# ────────────────────────────────────────────────────
# Copy the application code and install dependencies
# ────────────────────────────────────────────────────
COPY package*.json ./
RUN npm install

COPY . .

# ────────────────────────────────────────────────────
# Expose the application port (default: 4004)
# ────────────────────────────────────────────────────
EXPOSE 4004

# ────────────────────────────────────────────────────
# Define the command to run the CAP application
# ────────────────────────────────────────────────────
CMD [ "cds", "watch" ]

Also to avoid copying the node_modules folder, and other files not relevant for the image, we can create a .dockerignore file in the root of the project with the following content:

Copy
# Dependencies (critical — this is what's causing your ELF error)
node_modules
npm-debug.log*

# Git
.git
.gitignore

# Environment / secrets
.env
.env.*
default-env.json

# CAP-specific build artifacts
gen
.cds-services.json
.cds-services.json.bak
_out

# Editor / OS
.vscode
.idea
.DS_Store

# Docker itself
Dockerfile
.dockerignore

Now we can build the Docker image and run the CAP project in a container.

Copy
# Build the Docker image
docker build -t cap-docker-project:1.0.0 .
# Run the CAP project in a container
docker run -d -p 4004:4004 cap-docker-project:1.0.0

Finally, the application will be accessible at http://localhost:4004/ in your web browser.

If the app is not running, you can check the logs of the container using the following command:

Copy
docker logs <container_id>

Consuming Cloud Foundry services

With previous implementation the data is in an in-memory database. Let's add a HANA Cloud instance to the project, so we can persist the data in a real database and consume BTP services.

We want to deploy the project to a Cloud Foundry environment, so we will use the cf CLI to log in to our Cloud Foundry environment and create a new HANA Cloud instance in our BTP subaccount. Know more about HANA Cloud Provisioning here.

Copy
# Log in to Cloud Foundry
cf login -a <your-cloud-foundry-api-endpoint> -u <your-username> -p <your-password> -o <your-org> -s <your-space>

# Create a new HANA Cloud instance
cf create-service hana hdi-shared cap-docker-hdi

Now we can use HANA to the CAP project and bind the service instance we just created.

Copy
# Install the HANA Cloud client for CAP
npm i @cap-js/hana
# Add HANA Cloud to the CAP project
cds add hana
# Bind the HANA Cloud instance to the CAP project
cf cds bind --to cap-docker-hdi
# Deploy the CAP project to Cloud Foundry
cds deploy --to hana

Last we can run the CAP project in hybrid mode, using the HANA Cloud instance for data persistence.

Copy
# Run the CAP project in hybrid mode
cds watch --to hana

Consuming services within a Docker container

When running the CAP project in a Docker container, we need to ensure that the container can access the Cloud Foundry services. To do this, we can use the cf CLI within the container to log in to Cloud Foundry and bind the HANA Cloud instance.

For that we need to perform the following steps:

  1. Install the CF CLI in the Docker image.
  2. Use the cf CLI to log in to Cloud Foundry and bind the HANA Cloud instance within the container. We can run the container in interactive mode to perform these steps.
  3. To avoid cf login every time we run the container, we save login generated tokens in a volume, so we can reuse them in the next container runs.

Since we will run the app in hybrid mode, we have to make sure that the services we are consuming are bound to the CAP project. Let's add the binding commands we run before in the package.json file.

Copy
{
  ...
  "scripts": {
    "start": "cds-serve",
    "watch-hybrid": "cds watch --profile hybrid",
    "bind-services": "cds bind --to cap-docker-hdi"
  },
  ...
}

To automate the login and the initial steps to be done everytime we run the container, we can create a shell script that will be executed when the container starts. This script will check if the login tokens are present in the volume, and if not, it will perform the login. Let's call it docker-entrypoint.sh with the following content:

Copy
#!/bin/bash
set -e

# ─────────────────────────────────────────────
# Validate required environment variables
# ─────────────────────────────────────────────
MISSING_VARS=()

[ -z "$CF_API"   ] && MISSING_VARS+=("CF_API")
[ -z "$CF_ORG"   ] && MISSING_VARS+=("CF_ORG")
[ -z "$CF_SPACE" ] && MISSING_VARS+=("CF_SPACE")

if [ ${#MISSING_VARS[@]} -gt 0 ]; then
  echo ""
  echo "❌  Missing required environment variables: ${MISSING_VARS[*]}"
  echo "    Pass them with -e CF_API=... -e CF_ORG=... -e CF_SPACE=..."
  echo ""
  exit 1
fi

# ─────────────────────────────────────────────
# Persist CF CLI session across container runs
#
# The CF CLI stores its config + tokens in $CF_HOME/.cf (default: ~/.cf).
# Container filesystems are ephemeral, so without a volume mount that
# directory is recreated empty on every `docker run` and the user is
# forced to re-do SSO. Pin CF_HOME to a stable path and recommend
# mounting it as a named volume:
#
#   docker run -it -v cf-home:/cf-home  ...   <image>
# ─────────────────────────────────────────────
# If folder in $CF_HOME don't exist, create it.
if [ ! -d "$CF_HOME" ]; then
  mkdir -p "$CF_HOME"
fi

# Check if $CF_HOME is writable. If not, warn the user that session will not persist.
if ! find "$CF_HOME" -maxdepth 0 -writable >/dev/null 2>&1; then
  echo "⚠️   CF_HOME ($CF_HOME) is not writable — session will not persist."
fi

# ─────────────────────────────────────────────
# CF SSO Login (skip if already logged in to the
# requested API / org / space)
# ─────────────────────────────────────────────
ALREADY_LOGGED_IN=0
if CF_TARGET_OUTPUT=$(cf target 2>/dev/null); then
  CURRENT_API=$(printf '%s\n'   "$CF_TARGET_OUTPUT" | awk -F': +' '/^API endpoint:/ {print $2}' | tr -d '\r')
  CURRENT_ORG=$(printf '%s\n'   "$CF_TARGET_OUTPUT" | awk -F': +' '/^[Oo]rg:/           {print $2}' | tr -d '\r')
  CURRENT_SPACE=$(printf '%s\n' "$CF_TARGET_OUTPUT" | awk -F': +' '/^[Ss]pace:/         {print $2}' | tr -d '\r')

  if [ "$CURRENT_API" = "$CF_API" ] \
     && [ "$CURRENT_ORG" = "$CF_ORG" ] \
     && [ "$CURRENT_SPACE" = "$CF_SPACE" ]; then
    # `cf target` reports the cached target even when the access token
    # has expired. Probe with a cheap authenticated call to confirm
    # the session is actually still valid.

    if cf orgs >/dev/null 2>&1; then
      ALREADY_LOGGED_IN=1
    fi
  fi
fi

if [ "$ALREADY_LOGGED_IN" = "1" ]; then
  echo ""
  echo "🔓  Already logged in to Cloud Foundry — skipping SSO login."
  echo "    CF_HOME: $CF_HOME"
  echo "    API    : $CF_API"
  echo "    Org    : $CF_ORG"
  echo "    Space  : $CF_SPACE"
  echo ""
else
  if [ ! -t 0 ]; then
    echo "❌  No active CF session and no TTY for SSO login."
    echo "    Run the container interactively: docker run -it -v cf-home:/cf-home ..."
    exit 1
  fi

  echo ""
  echo "🔐  Connecting to Cloud Foundry via SSO..."
  echo "    CF_HOME: $CF_HOME"
  echo "    API    : $CF_API"
  echo "    Org    : $CF_ORG"
  echo "    Space  : $CF_SPACE"
  echo ""
  echo "    A browser link will appear below."
  echo "    Open it, copy the one-time passcode, and paste it here."
  echo ""

  cf login --sso \
    -a "$CF_API" \
    -o "$CF_ORG" \
    -s "$CF_SPACE"

  echo ""
  echo "✅  Logged in successfully."
  echo ""
fi

# ─────────────────────────────────────────────
# Bind services to the CAP application (if any)
# ─────────────────────────────────────────────
npm run bind-services

echo ""
echo "▶️   Handing off to: $*"
echo ""
# ─────────────────────────────────────────────
# Hand off to the CMD (or any command passed
# as arguments to `docker run`)
# ─────────────────────────────────────────────
exec "$@"

Besides, the docker-entrypoint.sh script expects several environment variables to be set when running the container. These variables are:

  • CF_API: The Cloud Foundry API endpoint.
  • CF_ORG: The Cloud Foundry organization to target.
  • CF_SPACE: The Cloud Foundry space to target.
  • CF_HOME: The directory where the CF CLI will store its configuration and tokens. This should be a volume mount to persist the session across container runs.

Let's define them in the Dockerfile and set the entrypoint to the script we just created, together with the CF CLI installation and the volume mount for the CF CLI session persistence.

Copy
# ────────────────────────────────────────────────────
# Base image: Node.js LTS (Debian slim variant)
# ────────────────────────────────────────────────────
FROM node:lts-slim

# ────────────────────────────────────────────────────
# Metadata: author, version, description
# ────────────────────────────────────────────────────
LABEL author="Rafael Lopez" \
      version="1.0" \
      description="A simple CAP application"

# ────────────────────────────────────────────────────
# Install prerequisites needed to add the CF CLI apt repository
# ────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
      wget \
      gnupg \
      ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# ────────────────────────────────────────────────────
# Add the official Cloud Foundry apt repository and install CF CLI v8
# Uses the modern `signed-by` approach (apt-key is deprecated in Debian 12+)
# ────────────────────────────────────────────────────
RUN wget -qO- https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key \
      | gpg --dearmor \
      | tee /usr/share/keyrings/cloudfoundry-cli.gpg > /dev/null \
    && echo "deb [signed-by=/usr/share/keyrings/cloudfoundry-cli.gpg] \
        https://packages.cloudfoundry.org/debian stable main" \
      | tee /etc/apt/sources.list.d/cloudfoundry-cli.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends cf8-cli \
    && rm -rf /var/lib/apt/lists/*

# ────────────────────────────────────────────────────
# Cloud Foundry connection parameters
# These are intentionally left empty — always supply them at runtime:
#   docker run -e CF_API=... -e CF_ORG=... -e CF_SPACE=... ...
# ────────────────────────────────────────────────────
ENV CF_API="___your_cloud_foundry_api_endpoint___"
ENV CF_ORG="___your_cloud_foundry_organization___"
ENV CF_SPACE="___your_cloud_foundry_space___"

# ────────────────────────────────────────────────────
# Persist the CF CLI session (config + tokens) outside the ephemeral
# container layer. Mount a named volume here to keep the SSO session
# across `docker run` invocations:
#   docker run -it -v cf-home:/cf-home  ...   <image>
# ────────────────────────────────────────────────────
ENV CF_HOME="/cf-home"
VOLUME ["/cf-home"]

# ────────────────────────────────────────────────────
# Install the SAP Cloud Application Programming Model (CAP) CLI tools
# ────────────────────────────────────────────────────
RUN npm i -g @sap/cds-dk

# ────────────────────────────────────────────────────
# Set the working directory for your application code
# ────────────────────────────────────────────────────
WORKDIR /app

# ────────────────────────────────────────────────────
# Copy the application code and install dependencies
# ────────────────────────────────────────────────────
COPY package*.json ./
RUN npm install

COPY . .

# ────────────────────────────────────────────────────
# Expose the application port (default: 4004)
# ────────────────────────────────────────────────────
EXPOSE 4004

# ────────────────────────────────────────────────────
# Entrypoint: performs the interactive CF SSO login, then execs CMD
# ────────────────────────────────────────────────────
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

# ────────────────────────────────────────────────────
# Define the command to run the CAP application
# ────────────────────────────────────────────────────
CMD ["/bin/bash"]

Finally it's time to build the Docker image and run the CAP project in a container:

Copy
# Build the Docker image
docker build -t cap-docker-project:1.0.0 .
# Run the CAP project in a container (interactive mode)
docker run -it -v cf-home:/cf-home -p 4004:4004 cap-docker-project:1.0.0

After running the container, you will be prompted to log in to Cloud Foundry via SSO. Once logged in, the script will bind the HANA Cloud instance to the CAP project and start the application, and will show the interactive terminal inside the container. Now we can run npm run watch-hybrid to start the CAP project in hybrid mode within the container and the app will be accessible at http://localhost:4004/ in the web browser.

Using Docker Compose

To simplify the process of running the CAP project in a Docker container, we can use Docker Compose. This allows us to define the services, networks, and volumes in a single docker-compose.yml file.

Furthermore we will mount a volume to allow live editing of the CAP project files from the host machine, so we can use our favorite IDE to edit the code and see the changes reflected in the running container.

Last but not least, we will use a .env file to store the environment variables needed for the Cloud Foundry login, so we don't have to pass them as command line arguments every time we run the container.

Add this .env file in the root of the project with the following content:

Copy
# Cloud Foundry target (used by docker-entrypoint.sh for `cf login --sso`).
CF_API=___your_cloud_foundry_api_endpoint___
CF_ORG=___your_cloud_foundry_organization___
CF_SPACE=___your_cloud_foundry_space___

# Host port to publish the CAP server on. Container port is always 4004.
CAP_PORT=4004

And this docker-compose.yml file in the root of the project with the following content:

Copy
# ─────────────────────────────────────────────────────────
# docker-compose.yml — cap-docker-project
#
# Wraps the existing Dockerfile + docker-entrypoint.sh so the project can be
# launched with a single command instead of a long `docker run` invocation.
#
# Usage
# ─────
#   1. Copy env template and adjust if needed:
#        cp .env.example .env
#
#   2. Start the container interactively (required for CF SSO passcode prompt):
#        docker compose run --rm --service-ports cap-app
#
#      `run` is used (not `up`) because `cf login --sso` needs a real TTY with
#      STDIN attached, which `up` doesn't forward cleanly. `--service-ports`
#      publishes the 4004 port that `run` would otherwise skip.
#
#   3. To rebuild after Dockerfile changes:
#        docker compose build
#
# Notes
# ─────
# * The CF SSO session is persisted in the named volume `cf-home`, so the
#   passcode prompt only appears on first run (or after token expiry).
# * The source directory is bind-mounted into /app for live editing, with an
#   anonymous volume on /app/node_modules to keep the image's installed deps
#   from being shadowed by the host's (often empty) node_modules folder.
# ─────────────────────────────────────────────────────────
version: '3.8'
services:
  cap-app:
    build:
      context: .
      dockerfile: Dockerfile
    image: cap-docker-project:local
    container_name: cap-docker-project

    # Interactive TTY — needed for `cf login --sso` passcode entry.
    stdin_open: true

    tty: true

    # CAP default port. Override with CAP_PORT in .env if you need another.
    ports:
      - "${CAP_PORT:-4004}:4004"

    # Cloud Foundry target. Defaults match the Dockerfile so the file is
    # optional, but a .env override avoids rebuilding for a different target.
    environment:
      CF_API:   "${CF_API}"
      CF_ORG:   "${CF_ORG}"
      CF_SPACE: "${CF_SPACE}"
      CF_HOME:  "/cf-home"

    volumes:
      # Persist the CF CLI session (config + tokens) across container runs.
      - cf-home:/cf-home

      # Bind-mount source for live editing.
      - .:/app

    # Inherits CMD ["/bin/bash"] from the Dockerfile — drops to a shell after
    # the entrypoint completes CF login + `npm run bind-services`. Override
    # at the CLI when you want a different command, e.g.:
    #   docker compose run --rm --service-ports cap-app cds watch

volumes:
  cf-home:
    name: cap-docker-cf-home

Finally we can run the CAP project in a Docker container using Docker Compose:

Copy
docker compose run --rm --service-ports cap-app