Skip to content

Running the Images

Sipke Schoorstra edited this page Aug 12, 2026 · 6 revisions

Running the Images

Docker run, Docker Compose, and Kubernetes.

All containers listen on port 8080 inside the container, regardless of image. Map it wherever you like on the host.

Endpoint Path
Studio UI / (on images containing Studio)
Elsa API /elsa/api
Readiness /health
Liveness /alive

Docker run

Combined — one container

docker run -d --name elsa-pro -p 8080:8080 \
  -v "$(pwd)/config.json:/config/config.json:ro" \
  -v elsa-data:/data \
  ghcr.io/valence-works/runtime-ce-combined:latest

Putting settings in a mounted config.json rather than a long list of -e flags keeps secrets out of docker inspect and your shell history. See Configuration.

Server + Studio — two containers

docker network create elsa

docker run -d --name elsa-server --network elsa -p 8080:8080 \
  -v "$(pwd)/server-config.json:/config/config.json:ro" \
  -v elsa-data:/data \
  ghcr.io/valence-works/runtime-ce-server:latest

docker run -d --name elsa-studio --network elsa -p 8081:8080 \
  -v "$(pwd)/studio-config.json:/config/config.json:ro" \
  ghcr.io/valence-works/runtime-ce-studio:latest

The URL wiring between them depends on the Studio hosting model — see Choosing an Image.


Docker Compose

A realistic starting point: combined image, PostgreSQL, persistent volumes, health checks.

services:
  elsa:
    image: valenceruntimeimages.azurecr.io/runtime-combined:3.8.0-preview.5413-build.62
    ports:
      - "8080:8080"
    volumes:
      - ./config:/config:ro
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: elsa
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
      POSTGRES_DB: elsa
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U elsa -d elsa"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  postgres-data:

./config/config.json:

{
  "CShells": {
    "Shells": {
      "Default": {
        "Features": {
          "DefaultAdminUser": {
            "AdminUsername": "admin",
            "AdminPassword": "ChangeThisPassword123!"
          },
          "Identity": {
            "SigningKey": "GENERATE_A_REAL_256_BIT_KEY"
          },
          "PostgreSqlWorkflowPersistence": {
            "ConnectionString": "Host=postgres;Port=5432;Database=elsa;Username=elsa;Password=..."
          },
          "PostgreSqlIdentityPersistence": {
            "ConnectionString": "Host=postgres;Port=5432;Database=elsa;Username=elsa;Password=..."
          }
        }
      }
    }
  }
}

PostgreSQL persistence requires the provider package to be loaded — see Extending with Nuplane.

docker compose up -d
docker compose logs -f elsa

depends_on with condition: service_healthy matters. Elsa applies migrations at startup; if the database isn't accepting connections yet, startup fails. The dependency waits for the healthcheck rather than merely for the container to exist. Note that the healthcheck here is on postgres, whose image ships pg_isready.

⚠️ Don't add a healthcheck: to the Elsa service. A Compose healthcheck runs a command inside the container, and these images are chiselled — no shell, no curl, no wget. The familiar test: ["CMD", "curl", "-f", "http://localhost:8080/health"] cannot execute, so the container is marked unhealthy forever, and anything with condition: service_healthy on it waits indefinitely. That is the trade for a minimal attack surface, and it is deliberate.

Probe from outside instead: curl -f http://localhost:8080/health on the host, your load balancer's health probe, or — in Kubernetes — an httpGet probe, which the kubelet performs itself and which needs nothing inside the image. See the manifest below.

Separate server and Studio

services:
  elsa-server:
    image: valenceruntimeimages.azurecr.io/runtime-server:3.8.0-preview.5413-build.62
    ports: ["8080:8080"]
    volumes: ["./config/server:/config:ro"]

  elsa-studio:
    image: valenceruntimeimages.azurecr.io/runtime-studio:3.8.0-preview.1667-build.62
    ports: ["8081:8080"]
    volumes: ["./config/studio:/config:ro"]
    depends_on: [elsa-server]

depends_on here is the plain form, not condition: service_healthy — the Elsa service has no healthcheck for the reason above, so waiting on one would never return. Studio tolerates the server arriving late: in WebAssembly mode the browser calls the API, and in Blazor Server mode the call happens per request, not at startup.

./config/studio/config.json — WebAssembly, so the URL is browser-reachable:

{
  "Studio": {
    "HostingModel": "WebAssembly",
    "Client": { "Backend": { "Url": "http://localhost:8080/elsa/api" } }
  }
}

And the server needs the matching CORS origin:

{ "Elsa": { "Cors": { "AllowedOrigins": ["http://localhost:8081"] } } }

For Blazor Server instead, drop the CORS entry and use a container-internal URL:

{
  "Studio": { "HostingModel": "BlazorServer" },
  "Backend": { "Url": "http://elsa-server:8080/elsa/api" }
}

Kubernetes

Sketch, not a production manifest — no TLS, ingress or autoscaling here.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: elsa
spec:
  replicas: 1
  selector:
    matchLabels: { app: elsa }
  template:
    metadata:
      labels: { app: elsa }
    spec:
      imagePullSecrets:
        - name: valence-registry       # created in Pulling Images
      # Required. The images run as UID 1654, while emptyDir and most
      # PersistentVolumes are created root-owned — without fsGroup the process
      # cannot write to its own mounts and startup fails.
      securityContext:
        runAsNonRoot: true
        runAsUser: 1654
        fsGroup: 1654
      containers:
        - name: elsa
          image: valenceruntimeimages.azurecr.io/runtime-combined:3.8.0-preview.5413-build.62
          ports:
            - containerPort: 8080
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
            - name: CShells__Shells__Default__Features__Identity__SigningKey
              valueFrom:
                secretKeyRef: { name: elsa-secrets, key: signing-key }
          volumeMounts:
            - name: config
              mountPath: /config
              readOnly: true
            # Extracted packages, Nuplane store state — and HOME, which is where
            # ASP.NET Data Protection keeps its key ring. The packages are
            # reproducible; the key ring is NOT. On this emptyDir a replacement
            # pod gets new keys and existing auth cookies stop being readable,
            # so users are signed out on every rollout. Fine for one replica if
            # you accept that; otherwise make it a PVC or persist the key ring
            # to a shared store. See the Production Checklist.
            - name: runtime-cache
              mountPath: /var/lib/valence-runtime
            # NuGet scratch space.
            - name: tmp
              mountPath: /tmp
            # Only needed with the default SQLite database — with an external
            # database, drop this mount and the volume below. If you keep
            # SQLite, this must be a PersistentVolumeClaim: an emptyDir here
            # loses every workflow when the pod is rescheduled.
            - name: data
              mountPath: /data
          readinessProbe:
            httpGet: { path: /health, port: 8080 }
            initialDelaySeconds: 15
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /alive, port: 8080 }
            initialDelaySeconds: 30
            periodSeconds: 30
          resources:
            requests: { memory: 512Mi, cpu: 250m }
            limits:   { memory: 2Gi,   cpu: "2" }
      volumes:
        - name: config
          configMap: { name: elsa-config }
        - name: runtime-cache
          emptyDir: {}
        - name: tmp
          emptyDir: {}
        - name: data
          persistentVolumeClaim: { claimName: elsa-data }

Use /health for readiness and /alive for liveness. /health includes dependency checks, so wiring it to liveness means a brief database blip restarts your pods — turning a recoverable degradation into a crash loop.

Scaling beyond one replica

Don't set replicas: 2 without first configuring distributed runtime coordination. Multiple instances sharing a database, with no distributed locking or a message bus, will double-execute scheduled and triggered workflows. You need a message bus (RabbitMQ or Azure Service Bus) and distributed runtime features enabled — see Extending with Nuplane.


Operating

All of these run on the host — the images are chiselled, so there is nothing to docker exec into.

docker logs -f elsa-pro                 # follow logs
curl -f http://localhost:8080/health    # readiness
docker stats elsa-pro                   # resource use

# what config actually landed
docker inspect elsa-pro --format '{{json .Config.Env}}' | tr ',' '\n' | grep CShells

Upgrading

# NEW_TAG is the immutable tag you are upgrading *to* — check the repository
# for the current one rather than guessing the build number.
NEW_TAG=<elsa-version>-build.<n>

docker pull valenceruntimeimages.azurecr.io/runtime-combined:$NEW_TAG
docker stop elsa-pro && docker rm elsa-pro
docker run -d --name elsa-pro ... valenceruntimeimages.azurecr.io/runtime-combined:$NEW_TAG

Back up your database first. Elsa applies schema migrations on startup, and migrations are not reversible by stopping the container — rolling back the image does not roll back the schema. Read the release notes before a minor-version jump.


Next: Configuration · Persistence and Databases · Production Checklist

Clone this wiki locally