Skip to content
falang.ioRU

Local setup

The workflow engine ships as open source, and you can run the whole thing locally — the fastest way to try it out. You’ll need Docker and kind (Kubernetes in Docker) installed: every running scenario executes in its own pod on a local k8s cluster, the same mechanism production uses.

  1. Create the local kind cluster:

    ./scripts/kind-dev-setup.sh
    The script is idempotent — safe to re-run, for example after changing the runner’s code, to rebuild and reload its image into the cluster.

  2. Copy .env.example to .env at the repo root. For a clean local run you don’t need to change anything — every secret (JWT_SECRET, CREDENTIALS_ENCRYPTION_KEY, etc.) has a dev-only default baked in (the bundled compose file also sets SEED_DEFAULT_ADMIN=true; the backend itself defaults it to false). With NODE_ENV=production the backend refuses to start unless JWT_SECRET, DB_PASSWORD and CREDENTIALS_ENCRYPTION_KEY are set to non-default values of at least 32 characters.

  3. Bring the stack up:

    docker compose -f docker-compose.workflow.yml up –build

  4. Open http://localhost:5175 and log in as admin/admin (auto-seeded the first time the backend boots), or register a new account from the login page.

  5. Create a project, then inside it a folder and a “Function” document: give the function a name and a body.
  6. Click “Build & Run” in the toolbar. This compiles every function in the project and creates a pod in the kind cluster to run them — shown under a task-queue name like workflow-dev-<projectId> (displayed in the toolbar next to the status dot, and via kubectl -n workflow get pods).

  7. Open the function and choose Dev → Run function in the toolbar (it asks for the function’s parameters, if any): the icon it is executing lights up on the canvas and a side panel shows its status and history. You can also start an execution with the Temporal admin CLI:

    docker compose -f docker-compose.workflow.yml exec temporal-admin-tools \
    temporal workflow start --task-queue workflow-dev-<projectId> --type <functionName> \
    --workflow-id demo-1 --input '{}'

    Watch it run in the Temporal Web UI: http://localhost:8080.

  8. “Publish” promotes the current code to the stable version (a shared task queue, workflow-<projectId>) — the “Versions” modal lets you roll back (“Activate”) or retire older versions (“Stop runner”). The version tagged “Current” is the one new runs and triggers go to and the one “Start” brings up; a rollback stays in effect across Stop/Start and while an idle runner sleeps.

docker-compose.workflow.yml brings up:

  • a self-hosted Temporal server (version 1.32, with its own Postgres, a one-shot schema job and a job creating the default namespace), its admin CLI and web UI;
  • a separate Postgres database for the backend (projects, folders, documents, users);
  • the backend — stores projects, compiles functions, and manages starting/stopping runner pods via the Kubernetes API;
  • the client — the web scheme editor (the Admin app is served at /admin);
  • MinIO — S3-compatible object storage for files (console at http://localhost:9001);

  • media — the ffmpeg service behind the Media integration;
  • activepieces — a helper service backing part of the integrations catalog (see Integrations).

Every published scenario runs not as a process inside the backend’s own container, but as its own pod in Kubernetes — which is why the local kind cluster is needed.

By default (TEMPORAL_TENANT_ISOLATION=shared) every project’s workflows run in Temporal’s single default namespace with no authorization — fine for local development and for an instance whose projects all belong to people you trust. A runner pod runs a project’s own code, so on a multi-tenant instance switch on per-project mode: each project gets its own namespace (falang-<projectId>), runner pods authenticate with a short-lived JWT valid for that namespace only, and cross-namespace commands are disabled on the server. Start the stack like this (use the same private key every time):

export TEMPORAL_JWT_PRIVATE_KEY="$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048)"
export PROJECT_TOKEN_SECRET="$(openssl rand -hex 32)"
TEMPORAL_SERVER_CONFIG=server.yaml TEMPORAL_TENANT_ISOLATION=per-project \
docker compose -f docker-compose.workflow.yml up --build

The Temporal server fetches the public key from the backend’s internal port (/internal/temporal/jwks.json), so a key rotation needs only a backend restart. In this mode the Temporal web UI on port 8080 can no longer log in; use the admin CLI instead, which talks to Temporal’s internal frontend (port 7236, never published to the host):

docker compose -f docker-compose.workflow.yml exec temporal-admin-tools \
temporal workflow list --namespace falang-<projectId>

The backend variables behind this mode:

  • TEMPORAL_TENANT_ISOLATION — shared (default) or per-project. In per-project mode the backend refuses to start without TEMPORAL_JWT_PRIVATE_KEY, and at startup it checks that Temporal really rejects requests without a token or with a token it can’t verify (it refuses to start if it doesn’t, because the isolation would be worth nothing).

  • TEMPORAL_JWT_PRIVATE_KEY — an RSA private key in PKCS#8 PEM format (RS256 signing). Optional: TEMPORAL_JWT_KEY_ID (defaults to the key’s thumbprint), TEMPORAL_JWT_PREVIOUS_PUBLIC_KEY (the old public key, published next to the new one while you rotate), TEMPORAL_JWT_TTL_SECONDS (pod token lifetime, default 3600; pods renew it on their own).

  • PROJECT_TOKEN_SECRET — the key the per-project tokens runner pods use to talk to the backend are derived from (in production, at least 32 characters and not a default value; the same value on every backend replica).

  • TEMPORAL_NAMESPACE_RETENTION_DAYS — how long finished executions are kept in each project’s namespace (default 7). TEMPORAL_TLS=true — if your Temporal frontend terminates TLS.

  • RUN_JOURNAL_RETENTION_DAYS_DEV / RUN_JOURNAL_RETENTION_DAYS_PROD — how many days the run journal keeps entries of dev-stand and published runs (both default 14; the journal outlives the engine’s own run history).

A deleted project’s namespace is removed by the backend about a day after the project is deleted. Switching an existing instance from shared to per-project does not move old executions out of default; the backend pauses the schedules it left there, and republishing a project starts it in its own namespace.

On the very first docker compose up after the falang-workflow-network network is (re)created, the backend container sometimes fails to attach to the second network (kind) in time — its logs show a steady stream of getaddrinfo EAI_AGAIN app-postgres errors. One command fixes it:

docker compose -f docker-compose.workflow.yml restart backend

It doesn’t recur once both networks are actually attached.

The backend listens on two ports. The public one (PORT, 4000 in the compose files) serves the application API and is the only one to publish through a reverse proxy or ingress. The internal one (INTERNAL_PORT, default 3001) serves only /internal/* — the API used by runner pods, the ActivePieces service and the media service; the public port answers 404 for those paths. Never expose the internal port to the internet: point BACKEND_INTERNAL_URL of those services at it (inside a cluster, a ClusterIP Service).

LOG_FORMAT=json makes the backend write one JSON object per line (level, timestamp, context, message and, inside an HTTP request, requestId — the same value the response carries in x-request-id; an incoming x-request-id of up to 128 safe characters is reused). The default is human-readable text. LOG_LEVEL optionally filters: one level (warn means warn and above) or a comma-separated list. GET /internal/metrics on the internal port exposes Prometheus metrics (process, Node.js, HTTP requests by route template, builds, runner pods, AI agent calls); there is no authentication, so keep that port private.