A Dapr Workflow demo using the Dapr JS SDK with an Express HTTP API. The app schedules durable workflows that query PostgreSQL through Dapr bindings, with Redis as the workflow state backend.
| Component | Technology |
|---|---|
| Language | TypeScript 6 |
| Runtime | Node.js 24 |
| Web framework | Express 5 |
| Workflow engine | Dapr Workflow via @dapr/dapr 3.6 |
| State store | Redis (via Dapr state component) |
| Data binding | PostgreSQL 18 (via Dapr binding component) |
| Container CLI | Podman (Docker-compatible) + Podman Compose |
| Testing | Vitest 4 (unit + integration) |
| Linting | ESLint 10 + typescript-eslint 8, hadolint for Dockerfile |
| Formatting | Prettier 3 |
| Security | gitleaks, Trivy filesystem scan, pnpm audit |
| CI/CD | GitHub Actions, Renovate, act (local CI) |
C4Context
title System Context — Dapr Node.js Workflow
Person(user, "API Consumer", "HTTP client calling REST endpoints")
System(sys, "Dapr Node.js Workflow", "Durable workflow service: schedules workflows that query Postgres via Dapr binding, with Redis-backed state for crash-safe replay")
Rel(user, sys, "Schedules and polls workflows", "HTTPS / JSON")
UpdateLayoutConfig($c4ShapeInRow="2")
make deps # bootstrap mise + install every pinned tool (node, pnpm, act, dapr, gitleaks, hadolint, trivy); check podman + git
make dapr-init # initialize Dapr (one-time; starts Redis, placement, scheduler)
make up # start PostgreSQL + Redis via Podman Compose
make start # build and start API server with Dapr sidecar (foreground)
# -> http://localhost:3000Every tool below (except make, podman, git) is pinned in .mise.toml / .nvmrc and installed in one step by make deps (which bootstraps mise if missing).
| Tool | Version | Purpose |
|---|---|---|
| GNU Make | 3.81+ | Build orchestration |
| mise | latest | Tool version manager — bootstrapped by make deps; reads .nvmrc + .mise.toml |
| Node.js | 24+ | JavaScript runtime (mise, via .nvmrc) |
| pnpm | 10.33.0+ | Package manager (mise, via .mise.toml) |
| Dapr CLI | 1.17.1+ | Dapr sidecar management (mise, via .mise.toml) |
| act | 0.2.87+ | Run GitHub Actions locally (mise, via .mise.toml) |
| Trivy | 0.69.3+ | Filesystem CVE/secret/misconfig scanner (mise, via .mise.toml) |
| gitleaks | 8.30.1+ | Secret scanner (mise, via .mise.toml) |
| hadolint | 2.14.0+ | Dockerfile linter, invoked by make lint (mise, via .mise.toml) |
| Podman | latest | Container runtime for PostgreSQL/Redis |
| Git | latest | Version control |
Install all required dependencies:
make depsC4Container
title Container View — Dapr Node.js Workflow
Person(user, "API Consumer")
System_Boundary(sys, "Dapr Node.js Workflow") {
Container(api, "Express API", "Node.js 24, TypeScript 6, Express 5", "REST endpoints; hosts the WorkflowRuntime and activity handlers")
Container(sidecar, "Dapr Sidecar", "daprd 1.17.5", "Workflow engine, component bindings, state store client")
ContainerDb(redis, "Redis", "Redis 8", "Dapr state store — durable workflow state for replay on restart")
ContainerDb(postgres, "PostgreSQL", "PostgreSQL 18", "Queried by fetchPostgresDataActivity via Dapr binding")
}
Rel(user, api, "Schedule / poll workflow", "HTTPS / JSON")
Rel(api, sidecar, "Workflow client + runtime", "gRPC :50001")
Rel(api, sidecar, "Binding invocation (from activities)", "HTTP :3500")
Rel(sidecar, redis, "Persist workflow state", "TCP :6379")
Rel(sidecar, postgres, "bindings.postgres", "TCP :5432")
- Express API + WorkflowRuntime run in the same Node process. The API handlers are thin — they schedule workflows via
DaprWorkflowClientover gRPC, and the sidecar's scheduler streams activity work items back to the runtime over the same gRPC connection. - Dapr Sidecar (
daprd, pinned to 1.17.5 viaDAPR_RUNTIME_VERSION) is the orchestrator. All state persistence, activity dispatch, and component I/O go through it. - Redis stores durable workflow state. Killing the app container mid-run and restarting it replays the workflow from Redis-persisted state — verified end-to-end by
make e2e-durability. - PostgreSQL is not used directly by the app. The
fetchPostgresDataActivityPOSTs a SQL query to the sidecar's binding HTTP API; the sidecar resolves it via thebindings.postgrescomponent and returns rows. See ADR-0001: Query PostgreSQL via Dapr binding for the rationale.
sequenceDiagram
autonumber
participant U as API Consumer
participant A as Express API<br/>(+ WorkflowRuntime)
participant D as Dapr Sidecar
participant P as PostgreSQL
U->>+A: POST /process-payload
A->>+D: scheduleNewWorkflow (gRPC)
D-->>-A: workflow id
A-->>-U: 202 { id }
rect rgb(245,245,245)
Note over D,A: Async activity dispatch via gRPC streaming
D->>A: delayActivity(ms)
A-->>D: ok
D->>A: modifyPayloadActivity(payload)
A-->>D: enriched payload
D->>A: fetchPostgresDataActivity
A->>D: POST /v1.0/bindings/postgres-db
D->>+P: SELECT * FROM users
P-->>-D: rows
D-->>A: rows
A-->>D: dbData
end
Note over D: Workflow state persisted to Redis after each activity
U->>+A: GET /workflow/:id/status
A->>+D: getWorkflowState (gRPC)
D-->>-A: runtime status + output
A-->>-U: 200 { status, output }
The delayActivity step defaults to 30 s (simulating a long-running request); tests and e2e-dapr override it to 0 via the request body. While a workflow is mid-flight, GET /workflow/:id/status returns RUNNING; once all activities complete, the same endpoint returns COMPLETED with the enriched JSON payload.
Validated end-to-end by make e2e-durability: the workflow is scheduled with a 15 s delay, the app container is killed mid-flight, restarted, and the workflow still completes with the full enriched payload (including the Postgres binding result) — replayed from Redis-persisted state.
sequenceDiagram
autonumber
participant U as Test Driver
participant A as Express API<br/>(killed, then restarted)
participant D as Dapr Sidecar<br/>(survives restart)
participant R as Redis
U->>A: POST /process-payload { delayMs: 15000 }
A->>D: scheduleNewWorkflow
D->>R: persist workflow state
D-->>A: workflow id
A-->>U: 202 { id }
D->>A: delayActivity(15000)
Note over A: ⏱ activity in flight
rect rgb(255,235,235)
Note over U,A: Test kills the app container at t≈5s
U-x A: docker kill
end
rect rgb(235,250,235)
Note over U,A: Test restarts the app container — sidecar untouched
U->>A: docker run (same image)
A->>D: re-register WorkflowRuntime (gRPC)
end
Note over D,R: Sidecar resumes from Redis state, replays remaining activities
D->>A: delayActivity (resumed)
A-->>D: ok
D->>A: modifyPayloadActivity
A-->>D: enriched payload
D->>A: fetchPostgresDataActivity
A->>D: POST /v1.0/bindings/postgres-db
D-->>A: dbData
A-->>D: dbData
D->>R: persist final state
U->>A: GET /workflow/:id/status
A->>D: getWorkflowState
D-->>A: COMPLETED + output (processed:true, dbData)
A-->>U: 200 { status: "COMPLETED", output }
The Dapr sidecar is left running across the kill — only the app container is replaced. This mirrors the production failure mode where an app pod crashes and is rescheduled while the sidecar (or its replacement) keeps Redis state durable.
| Service | Port | Protocol | Purpose |
|---|---|---|---|
| Express API | 3000 | HTTP | REST endpoints |
| Dapr sidecar | 3500 | HTTP | Binding calls from activities |
| Dapr sidecar | 50001 | gRPC | WorkflowClient / WorkflowRuntime |
| Dapr scheduler | 50006 | gRPC | Workflow scheduling |
| PostgreSQL | 5432 | TCP | Database backend |
| Redis | 6379 | TCP | Dapr state store |
src/
api-server.ts Entrypoint: imports app, calls listen, wires SIGINT
app.ts Express app, lazy-init Dapr workflow client (exported for tests)
data-request-workflow.ts Workflow definition and activities
__tests__/
*.test.ts Unit tests (Vitest + supertest)
*.integration.test.ts Integration tests (require running Dapr stack)
e2e/
e2e-dapr.sh Full-stack e2e: production image + Dapr sidecar
e2e-durability.sh Durability e2e: kill app mid-flight, assert resume
components/ Dapr component configs (local dev)
dapr/ci/ Dapr component configs (CI)
db/ SQL schema and seed data
docker-compose.yaml PostgreSQL + Redis for local development
# Terminal 1 -- start infrastructure and server
make up # start PostgreSQL + Redis via Podman Compose
make start # build and start API server with Dapr sidecar (foreground)
# Terminal 2 -- verify
make check-db # run database health check workflow
make check-workflow # trigger a test workflow and poll the resultmake start-no-dapr
curl http://localhost:3000/make stop # stop Dapr sidecar and API server
make down # stop PostgreSQL + Redis containers| Method | Path | Description |
|---|---|---|
GET |
/ |
Health check |
POST |
/process-payload |
Schedule a new workflow; returns { id } (202). Empty body returns 400. Optional delayMs in body. |
GET |
/workflow/:id/status |
Poll workflow state; output present when status == "COMPLETED". Unknown id returns 404. |
GET |
/db-health |
Schedule a workflow and wait up to 10s for DB result |
Capture the generated workflow id into a shell variable so every subsequent poll reuses it:
# Schedule and capture the id in one shot
WF_ID=$(curl -s -X POST http://localhost:3000/process-payload \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "data": {"key1": "value1"}}' \
| jq -r .id)
echo "$WF_ID"
# -> 82236756-4f38-4b5f-9796-a1268184561e
# Poll (while the 30s delay activity is running)
curl -s "http://localhost:3000/workflow/$WF_ID/status" | jq .While running:
{
"id": "82236756-4f38-4b5f-9796-a1268184561e",
"status": "RUNNING",
"createdAt": "2026-04-16T16:34:44.118Z",
"lastUpdatedAt": "2026-04-16T16:34:47.139Z"
}After completion (output is present):
{
"id": "82236756-4f38-4b5f-9796-a1268184561e",
"status": "COMPLETED",
"output": "{\"name\":\"John Doe\",\"processed\":true,...}",
"createdAt": "2026-04-16T16:34:44.118Z",
"lastUpdatedAt": "2026-04-16T16:35:21.199Z"
}Poll until done using the same variable:
until curl -s "http://localhost:3000/workflow/$WF_ID/status" | jq -e '.status == "COMPLETED"' > /dev/null; do
sleep 2
done
curl -s "http://localhost:3000/workflow/$WF_ID/status" | jq .make test # run Vitest unit tests (activity logic + supertest HTTP)
make test-watch # run unit tests in watch modeIntegration tests require the full Dapr stack (PostgreSQL + Redis + Dapr sidecar):
# Terminal 1
make up # start PostgreSQL + Redis
make start # start API server with Dapr
# Terminal 2
make integration-testmake e2e runs the production Docker image standalone and verifies the Dapr-unreachable error path (shallow e2e, no sidecar). make e2e-dapr builds the image and runs it alongside a real Dapr sidecar to assert a workflow COMPLETES end-to-end. make e2e-durability additionally kills the app container mid-flight and asserts the workflow resumes from Redis-persisted state.
make ci # run static-check, test, build locally
make ci-run # run GitHub Actions workflow locally via act (requires Docker)The
integration-testGitHub Actions job uses service containers not supported byact. Test integration locally with the steps above.
Run make help to see all targets in one list.
| Target | Description |
|---|---|
make help |
List all available tasks |
make deps |
Bootstrap mise (once) and install every pinned tool (node from .nvmrc; pnpm, act, dapr, gitleaks, hadolint, trivy from .mise.toml); check podman + git |
make install |
Install npm dependencies (uses --frozen-lockfile when CI=true) |
make clean |
Remove build artifacts and node_modules |
| Target | Description |
|---|---|
make build |
Build TypeScript to dist/ |
make format |
Auto-fix formatting with Prettier |
make format-check |
Check formatting without modifying files |
make lint |
Run Prettier check, ESLint, TypeScript noEmit, and hadolint |
make vulncheck |
Audit dependencies for known vulnerabilities |
make secrets |
Scan for hardcoded secrets with gitleaks |
make trivy-fs |
Scan filesystem for vulnerabilities, secrets, and misconfigurations |
make deps-prune |
Show unused/redundant Node.js dependencies |
make deps-prune-check |
Verify no prunable dependencies (CI gate) |
make components-check |
Drift gate: fails if components/*.yaml and dapr/ci/*.yaml differ beyond password/comments |
make mermaid-lint |
Validate Mermaid diagrams in README.md + CLAUDE.md via pinned minlag/mermaid-cli |
make static-check |
Composite quality gate (lint + vulncheck + secrets + trivy-fs + deps-prune-check + components-check + mermaid-lint) |
| Target | Description |
|---|---|
make test |
Run unit tests |
make test-watch |
Run unit tests in watch mode |
make integration-test |
Run integration tests (requires running Dapr stack) |
make smoke |
HTTP smoke test against built server (no Dapr) |
| Target | Description |
|---|---|
make dapr-init |
Initialize Dapr in local environment (stops conflicting Redis if needed) |
make up |
Start PostgreSQL and Redis via Podman Compose |
make down |
Stop infrastructure services and remove containers |
make postgres-start |
Start PostgreSQL in Podman |
make postgres-stop |
Stop PostgreSQL Podman container |
| Target | Description |
|---|---|
make start |
Build and start the API server with Dapr sidecar |
make stop |
Stop the Dapr sidecar and API server |
make start-no-dapr |
Build and start the API server without Dapr sidecar |
make run |
Alias for start-no-dapr |
make check-workflow |
Trigger a test workflow and print the result |
make check-db |
Run the database health check endpoint |
| Target | Description |
|---|---|
make check |
Run full local verification (static-check, test, build; static-check runs lint which runs prettier --check) |
make ci |
Run local CI pipeline (static-check, test, build; static-check runs lint which runs prettier --check) |
make ci-run |
Run GitHub Actions workflow locally via act |
make ci-run-tag |
Run GitHub Actions workflow locally with a tag event (exercises docker job) |
make release VERSION=vX.Y.Z |
Create and push a release tag |
The
ci-seed-db,ci-dapr-start,docker-smoke-test,dast-scan, anddocker-verify-manifestMakefile targets are called exclusively from CI (service-container provisioning, pre-push image gating, and multi-arch manifest verification). They are not intended for local use — usemake up+make startlocally instead.
| Target | Description |
|---|---|
make image-build |
Build the production Docker image (multi-stage) |
make image-run |
Run the Docker image standalone (no Dapr) |
make image-stop |
Stop the running image container |
make e2e |
Shallow e2e: production image standalone, verifies the Dapr-unreachable error path |
make e2e-dapr |
Full-stack e2e: production image + Dapr sidecar, asserts a workflow COMPLETES end-to-end |
make e2e-durability |
Workflow replay e2e: kills the app mid-flight, asserts the workflow resumes from Redis-persisted state |
make dast |
ZAP baseline DAST scan against the built image |
| Target | Description |
|---|---|
make update |
Update dependencies to latest allowed versions |
make upgrade |
Upgrade dependencies to latest versions (ignoring ranges) |
make renovate |
Run Renovate locally in dry-run mode |
make renovate-validate |
Validate Renovate configuration |
GitHub Actions runs on every push to main, version tags (v*), and pull requests. The workflow is reusable via workflow_call.
| Job | Depends on | Steps |
|---|---|---|
| changes | — | dorny/paths-filter detector — emits code=true for code changes, code=false for doc-only (*.md, docs/**, image assets; CLAUDE.md is re-included as project config). All heavy jobs gate on this output, so doc-only PRs skip them and ci-pass reports green via skipped-jobs. Replaces trigger-level paths-ignore (Rulesets-incompatible). |
| static-check | changes | make static-check (Prettier check, ESLint, tsc --noEmit, hadolint, pnpm audit, gitleaks, Trivy fs scan, depcheck, components-check, mermaid-lint) |
| build | changes, static-check | make build + make smoke (HTTP smoke test against the built server) |
| test | changes, static-check | make test (Vitest unit tests — activity logic, checkPort, supertest HTTP) |
| e2e | changes, build, test | make e2e (shallow: standalone image, validates health endpoint + Dapr-unreachable error path) |
| e2e-dapr | changes, build, test | make ci-seed-db + build image + ./e2e/e2e-dapr.sh (production image alongside dapr run sidecar, asserts workflow COMPLETES). Skipped under act. |
| integration-test | changes, build, test | make ci-seed-db, make build, make ci-dapr-start, make integration-test (PostgreSQL service container + Dapr CLI 1.17.1). Skipped under act. |
| dast | changes, build, test | Build image via cache-from: type=gha, make docker-smoke-test, cached ZAP image, make dast-scan, upload report artifact. Skipped under act. |
| docker | changes, static-check, build, test | Runs every push in parallel with e2e/dast; gates 1–4 (build + Trivy + smoke + multi-arch build) always run, registry push + cosign signing are tag-gated (v*) at step level |
| ci-pass | all of the above | Gate job: fails if any upstream job failed |
The docker job runs the following gates before any image is pushed to GHCR. Any gate failure blocks the release.
| # | Gate | Catches | Tool |
|---|---|---|---|
| 1 | Build local single-arch image | Build regressions on the runner architecture | docker/build-push-action with load: true |
| 2 | Trivy image scan (CRITICAL/HIGH blocking) | CVEs in the base image, OS packages, build layers | aquasecurity/trivy-action with image-ref: |
| 3 | Smoke test | Image boots correctly on its own (Node.js boot-marker grep) | make docker-smoke-test |
| 4 | Multi-arch build + push | Publishes for linux/amd64 and linux/arm64 |
docker/build-push-action |
| 5 | Multi-arch manifest verification | Asserts image index has both platforms and no unknown/unknown (catches attestation leaks) |
make docker-verify-manifest |
| 6 | Cosign keyless OIDC signing | Sigstore signature on the manifest digest | sigstore/cosign-installer + cosign sign |
The dast job runs in parallel with the docker job and performs an additional security scan:
| Gate | Catches | Tool |
|---|---|---|
| OWASP ZAP baseline scan | Missing security headers, misconfigs, info leaks | make dast-scan (OWASP ZAP -I = warn only, report uploaded as artifact) |
Buildkit in-manifest attestations (provenance + sbom) are disabled so the image index stays free of unknown/unknown platform entries, which lets GHCR's Packages UI render the "OS / Arch" tab for the multi-arch manifest. Cosign keyless signing still provides the Sigstore signature for supply-chain verification.
Verify a published image's signature:
cosign verify ghcr.io/andriykalashnykov/dapr-nodejs-workflow:<tag> \
--certificate-identity-regexp 'https://github\.com/AndriyKalashnykov/dapr-nodejs-workflow/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe cleanup-runs.yml workflow runs weekly to delete old workflow runs and stale caches via the native gh CLI.
Renovate keeps dependencies up to date with platform automerge enabled. Tool versions pinned in the Makefile are tracked via inline # renovate: comments.
| Name | Type | Used by | How to set |
|---|---|---|---|
ACT |
Variable | integration-test, e2e-dapr, dast jobs |
Set to true under nektos/act to skip jobs that need service containers or docker-in-docker bind mounts (integration-test, e2e-dapr, dast). Set via Settings > Secrets and variables > Actions > Variables tab > New repository variable. |
GITHUB_TOKEN is provisioned automatically by GitHub Actions; no manual setup is needed.
Contributions welcome — open a PR.
This project is licensed under the MIT License — see LICENSE for details.