Skip to main content

Run your pipeline locally

loha run --local runs the jobs of your .loha.yml in containers on your own machine, so you can see a step fail (or pass) before you push. It runs the same plan that loha plan shows: the same images, scripts, user ids, capabilities, working directory and CPU and memory limits, rendered by the Loha server. Only the parts that cannot exist on a laptop are replaced, and the summary at the end tells you which.

A local run is private to your machine. It creates no build on the platform, reports nothing back, and makes no request other than the plan request.

Local runs are on for every workspace

loha run --local is part of the local-run feature, which is on by default. If a Loha platform administrator has switched it off for your workspace, the command stops with Local runs are not enabled for this workspace. Ask a workspace admin to turn on local_run. and exits with code 2.

Before you start​

You need a container engine and a checkout of a repository onboarded to Loha, the same as for loha plan.

  • Docker Desktop and Docker Engine are the tested engines. docker is the default.
  • Podman works on a best-effort basis. Choose it with --engine podman or LOHA_CONTAINER_ENGINE=podman (the flag wins).
  • Without an engine the command exits with code 3 and says No container engine found. Install Docker Desktop or Docker Engine, or pass --engine podman. before it contacts the server. If the engine is installed but not running, its own message is printed first.
  • Images are pulled from their registry (see Engine notes for when). An image from your workspace registry needs your own docker login; Loha never hands out registry credentials to the CLI.

Usage​

Run it from inside the checkout:

loha run --local
Run local-3f9c2ab1, logs in /home/you/app/.loha/local-runs/local-3f9c2ab1/logs
Job test
✓ test/wait-for-services (3.4s)
✓ test/checkout (0.9s)
[test/migrate] applying 12 migrations
✓ test/migrate (5.1s)
[test/unit] 148 passed
✓ test/unit (41s)

JOB STEP MODE RESULT TIME
test checkout emulated passed 0.9s
test migrate run passed 5.1s
test unit run passed 41s

≠ Source: staged working tree, not a clone of the pushed commit
≠ Network: this machine's network, not build-pod egress rules
≠ Services: containers sharing one network namespace, not pod sidecars

The values above are examples. Container output is shown as [job/step] line, with the same job and step names as the build page. By default jobs run one at a time, in the order of their needs; a matrix runs its combinations one after another, and --parallel N runs up to N ready jobs at once (see Parallel jobs). Disabled jobs and steps are listed with the reason, the same text loha plan prints.

When the server sends a trigger result, the run first prints what the pipeline's on: block does with the event you planned: ! On: would fire (<reason>) or ! On: would not fire (<reason>). It goes on either way: a local run is yours to start whenever you like. loha plan prints the same answer as its On: line.

Flags​

The plan flags are the ones loha plan has and mean the same thing: they decide which plan is run.

FlagPurpose
--localRequired. loha run without it prints a hint to use loha deploy and exits with 2
--file, --branch, --event, --sha, --repo, --trigger, --var KEY=VALUEPlan flags, as for loha plan
--base <branch>, --tag <tag>Simulate a pull request's target branch (--event pull_request) or a pushed tag (--event tag), as for loha plan. Any other event with these flags is a usage error
--engine docker|podmanContainer engine (default: LOHA_CONTAINER_ENGINE, else docker)
--job <id>Run only this job and the jobs it needs, repeatable
--parallel NRun up to N ready jobs at once, 1 to 16 (default 1). Cannot be combined with --mount-source (see Parallel jobs)
--matrix KEY=VALUERun only the matrix instances whose matrix has this value, repeatable, all must match (see Matrix)
--step <job>/<step>Run only this step. Needs --reuse (see Rerun a step)
--from <job>/<step>Run this step and everything after it. Needs --reuse. Cannot be combined with --step
--reuse <volume>Use the source volume that an earlier --keep run left behind as the workspace, as it is
--shell <job>/<step>Run everything before that step, then open /bin/sh in its container instead of running it. Needs a terminal; cannot be combined with --json, --step, --from or --parallel
--approveAccept every approval gate without asking (see Approvals)
--secret NAMEGive steps the secret NAME, with the value read from your environment variable of the same name, repeatable
--env-file <path>File of KEY=VALUE lines supplying secrets and plain variables whose values the plan withholds (see Secrets and variables)
--mount-sourceBind-mount your working tree instead of copying it. Faster, but build output is written into your tree and results may differ from the cluster
--committed-onlyTake the pipeline file and the sources from HEAD and ignore local changes. Cannot be combined with --mount-source
--no-limitsDo not apply the plan's CPU and memory limits
--platform linux/amd64Run containers for this platform (emulated when it differs from your machine)
--keepKeep the source volume after the run and print its name
--jsonPrint one JSON event per line on stdout instead of text (see Machine-readable output)

Every combination that cannot work is refused with exit code 2 before the engine is started and before any request is made: --parallel outside 1 to 16, --step or --from without --reuse, --step together with --from, --shell together with --json, --step, --from or --parallel, --shell without a terminal on both stdin and stdout (--shell needs an interactive terminal.), --reuse or --parallel together with --mount-source, a --matrix entry that is not KEY=VALUE or repeats a key, --tag with an event other than tag, and --base with an event other than pull_request. Names that only the plan can check, such as an unknown job or step or a --matrix selector that matches nothing (an empty value, --matrix KEY=, included), are also exit 2, as soon as the plan is known. So is a --reuse volume that does not exist or is not a kept loha run volume for this repository.

Exit codes​

CodeMeaning
0Every enabled step passed
1A step failed or timed out, the run was cancelled with Ctrl-C, or the run could not be set up (for example the engine failed while creating the workspace)
2The plan was rejected (the server's errors are printed exactly as loha plan prints them), or a usage error: a missing --local, a bad flag or flag combination, an unknown --job, --step, --from, --shell or --matrix target, a refused --reuse volume, a missing --secret variable, local runs not enabled, a server or authentication error
3No container engine, or it is not running

A step that exits with 137 is reported as killed: likely out of memory at the <class> limit (<memory>), naming the job's resource class and its memory limit, so you can tell an out-of-memory kill from a failing test. With --no-limits, or when the plan sets no memory limit, it is reported as killed (exit 137), possibly out of memory.

A script step that exits with 126, 127 or 255 is reported as exited with code <n>; the image may have no /bin/sh (the step's script needs one): the script is run by /bin/sh, so an image without a shell (a scratch or distroless image) cannot run a run: script. Use an image that has one, or a step with a command.

When a job fails before any of its steps ran, for example because a service would not start, the text output prints ✗ <job>: <reason> so the cause is on the screen and not only in the log files.

What runs​

Each step has a badge in loha plan, and the local run follows it:

  • run steps run as written: the same image and script, in a container started with the plan's user, --cap-drop ALL plus the capabilities the plan asks for, --security-opt no-new-privileges, and the job's CPU and memory limits. The memory limit is also passed as --memory-swap, so the container cannot swap past it: cluster nodes have no swap, and a step that runs out of memory there runs out locally too. Containers are never privileged.
  • emu steps are replaced by a local equivalent. The checkout is replaced by staging your sources (see Source); the cache task cache@v2, upload-artifact and the job outputs that artifacts-save and artifacts-restore carry run their real scripts against a small server on your machine (see Cache and Job outputs and artifacts); image builds (buildah-build, docker-build) are built by your container engine (see Image builds); and approval gates ask you in the terminal (see Approvals).
  • skip steps do not run on your machine: anything that pushes, deploys, notifies or signs, and the managed security gate. A git-clone of this repository is recognised by its group/project path, so an SSH host alias or a mirror URL still counts. A clone of a different repository is skipped with Clones another repository; not available locally., and a note names both URLs (without credentials). When nothing else staged your sources, a second note says Nothing was staged for <job>; later steps see an empty workspace.

The first failure stops the run. After a step fails, its job's remaining steps show not run, jobs that need the failed job show skipped: needs <job>, which failed, and other jobs that had not started show not run: the run failed. This is how the cluster behaves too: it stops scheduling once a task has failed.

Jobs share one volume for the whole run, so a file written by an earlier job is there for a later one. The exception is a matrix: each instance gets its own empty workspace, as it does on the cluster, and receives earlier jobs' outputs through artifacts-restore (see Job outputs and artifacts).

Step timeouts and retries from the plan are honoured, and the pipeline timeout applies to the whole run. There is no separate job timeout.

Matrix​

A matrix arrives already expanded, one job per combination, and each instance gets its own workspace, so two instances never write the same files. By default the instances run one after another.

--matrix KEY=VALUE runs only the instances whose matrix has that value, for example --matrix node=22 to try one combination. It is repeatable and every entry must match (--matrix node=22 --matrix os=linux). A selector applies only to jobs that have that axis: jobs without a matrix, and jobs whose matrix has no such axis, run as usual. A selector that matches no instance at all, or names an axis no job has, stops the run with exit 2 and lists the axes there are. An empty value (--matrix node=) matches no instance, so it stops the run the same way.

Parallel jobs​

--parallel N (1 to 16, default 1) lets up to N jobs run at once: any job whose needs have passed, not only matrix instances, as the cluster runs independent jobs together. Output lines keep their [job/step] prefix, so interleaved logs stay readable. The first failure still stops new jobs from starting; jobs already running finish.

--parallel cannot be combined with --mount-source, because jobs would write your working tree at the same time. The resource check looks at each job on its own, not at N jobs together, so with a high N your engine can be asked for more than it has; lower N or give the engine VM more resources if jobs are killed.

Cache​

A step that uses cache@v2 runs its real script. A local server on your machine answers the script's requests, so a second run restores what the first run saved.

  • The cache is per machine and per repository, kept under loha/local-cache/ in your user cache directory (~/Library/Caches on macOS, ~/.cache on Linux, %LocalAppData% on Windows). It is never uploaded.
  • A restore needs an exact key match. There is no fallback to the default branch's cache, no size budget and no rate limit as on the cluster. An invalid key is refused, as it is on the cluster.
  • Nothing is evicted. To clear it, delete the folder: rm -rf <user cache dir>/loha/local-cache (or one repository's folder inside it).

Job outputs and artifacts​

upload-artifact, artifacts-save and artifacts-restore run their real scripts against a local server, so a matrix instance receives the files that earlier jobs saved, and upload-artifact files land in .loha/local-runs/<run id>/artifacts/ (see Output files). When two steps, such as two matrix instances, upload the same file name under the same artifact name, the second is stored as artifacts/<name>/<step>/<file>, so neither overwrites the other.

The local servers (cache, artifacts, the scan-report collector) listen only on a loopback or container-bridge address of your machine, never on all interfaces. Every route carries a random per-run secret in its path, and containers reach the server at host.docker.internal. The cache and artifact tokens are random for each run. Because the upload and download of the data itself is a plain URL, the blob token travels in the URL query over the host gateway; it is valid only for that run.

Image builds​

buildah-build and docker-build steps build with your container engine, from the workspace volume (the same files, including what earlier steps produced) with the plan's Dockerfile, build arguments and target.

  • The image is tagged loha-local/<name>:<run id>, where <name> is the last part of the step's image without its tag, lower-cased and cleaned to a-z 0-9 . _ -.
  • It is never pushed, whatever the step's other parameters say. The image is labelled io.loha.local-run=<run id> but, unlike containers, is not removed when the run ends: you can docker run it afterwards, and delete it with docker rmi when you are done.
  • The context's .dockerignore applies, as it does for Kaniko and Buildah on the cluster: the CLI filters the context with Docker's rules before the engine sees it, so excluded files (.env, .git, node_modules …) are never sent. .dockerignore and the Dockerfile are always sent. A line the CLI cannot apply exactly (such as ** inside a name) is reported as Could not apply .dockerignore line <n> locally; the cluster builder may exclude more.
  • With a buildx builder whose driver is not docker (for example docker-container), the build adds --load, so the image lands in your engine's image list.
  • Build argument values reach the engine through its environment, not the command line. A build argument named like a variable the engine client reads itself (DOCKER_*, BUILDX_*, BUILDKIT_HOST, BUILDKIT_PROGRESS, HTTP_PROXY, HTTPS_PROXY, NO_PROXY and their lowercase forms) fails the step.
  • The plan's build container limits (CPU, memory) do not apply to a local build.
  • The step is labelled emulated. Your engine's builder is not the cluster's: an engine without BuildKit can reject a Dockerfile that uses RUN --mount or a heredoc.

Approvals​

An approval gate stops the run and asks in the terminal:

Approve deploy? Deploy to staging [y/N]

y or yes (any case) approves. Anything else, an empty line included, declines, and so does the gate's timeoutSeconds passing. A declined or timed-out gate fails, with approval declined or approval timed out, and stops the run as a failed step does, as a rejected gate fails its task on the cluster. Answers are never written to the logs.

Without a terminal (a pipe, a CI job, --json) there is nobody to ask: the gate fails at once with Approval required for <job>; run in a terminal or pass --approve. Pass --approve to accept every gate; a notice says Approvals are accepted automatically (--approve).

Rerun a step and open a shell​

To iterate on one failing step without redoing everything before it, keep the workspace:

loha run --local --keep                              # prints the kept volume's name
loha run --local --reuse <volume> --from build/test # that step and everything after it
loha run --local --reuse <volume> --step build/test # only that step
loha run --local --shell build/test # a shell where that step would run
  • --step and --from need --reuse <volume>, a volume an earlier --keep run of the same repository left behind; without one the run would silently start from an empty workspace. The volume is used as it is: nothing is staged into it or removed from it. A volume kept by an older CLI has no io.loha.repo-key label and cannot be reused; run again with --keep to get one that can.
  • Earlier jobs and steps are reported skipped: before --from. That includes an approval gate in the same job: it is skipped with the steps before the target, not asked again.
  • A matrix instance cannot be targeted, because its own workspace is not kept by --keep. Target a job without a matrix.
  • --shell <job>/<step> runs everything before that step normally, then opens /bin/sh in the step's container, with its image, user, environment, mounts and working directory, instead of running its script. The step is reported skipped: shell and the run ends when you leave the shell. The shell is not bounded by the pipeline timeout. Ctrl-C goes to the shell inside the container, as in any terminal: leave with exit or Ctrl-D. A SIGINT sent to loha from elsewhere (kill -INT) still ends the shell and cleans up. If a killed shell leaves your terminal in raw mode, type reset. A step that does not run in a container locally (an emu step) has no shell to open.
  • The shell session is raw: what you type and what it prints is not masked and not logged, so env prints secret values in clear.
  • --shell needs a terminal on both stdin and stdout, and cannot be combined with --json, --step, --from or --parallel.

Source​

The cluster always builds from a clean clone of the pushed commit. Locally the CLI stages your sources into a fresh volume, when the first git-clone step of your repository runs, and into the directory that step names:

  • Default: the files git add -A would commit (tracked and untracked, not ignored), plus a shallow .git whose HEAD is your HEAD, so git rev-parse HEAD works and your uncommitted changes show up as modifications. Copying, rather than mounting, keeps a stale local node_modules or build output from hiding a failure that the cluster would show.
  • --committed-only: HEAD only, as the cluster would see it after you push the current commit. The pipeline file is also read from HEAD.
  • --mount-source: your working tree is mounted directly. The CLI prints Build outputs will be written into your working tree, and results may differ from the cluster.

A second git-clone into the same directory prints already staged and does nothing, so it cannot wipe outputs that were restored in between. A pipeline with no git-clone step starts with an empty volume.

Services​

A job's services (a database, a cache) run for the duration of the job, in the same network namespace as the job's steps, as pod sidecars do on the cluster. They answer on localhost:<port> and by their name and aliases, so a connection string written for the cluster, and the plan's own readiness check that probes 127.0.0.1, both work unchanged. The readiness check runs before the job's steps, as on the cluster, and the services are removed when the job ends.

Only the steps the plan marks as using the services network (your run: steps) join it. Steps that use an official task (uses:) do not, because on the cluster they run in their own pods and cannot reach the sidecars either.

Secrets and variables​

Loha never sends secret values to your machine, so a step that is granted a secret has no value for it until you supply one:

  • --secret NAME reads the value from the environment variable NAME of your shell. If it is not set the command stops with --secret NAME: environment variable NAME is not set.
  • --env-file <path> reads KEY=VALUE lines. It supplies secrets and, when your token lacks variables:values:read, the values of plain variables that the plan lists by name only, for steps and services alike.

A granted secret with no local value prints the warning the cluster prints for a missing secret, and the step runs without it.

The --env-file format:

  • One KEY=VALUE per line; blank lines and lines starting with # are ignored.
  • A name is letters, digits and _, and does not start with a digit.
  • A value in double quotes understands \n, \" and \\; any other backslash stays as written. Use it for multi-line values such as a private key.
  • A value in single quotes is taken literally.
  • An unquoted value is taken as written, including whitespace after the =, and loses only trailing whitespace.
  • A quoted value may be followed by a # comment. A repeated key keeps the last value.

A malformed file stops the command with exit 2 and names the line, never the value.

Values never go on a command line: they are handed to the container engine through its own environment, so they do not appear in ps or your shell history. These values are replaced by *** in the terminal output, the JSON events and the log files:

  • every --secret value;
  • every plain variable the plan marks masked, in steps and services;
  • an --env-file value whose name a step uses as a secret, or that the plan marks masked anywhere, or that no step or service uses as a plain variable.

An --env-file value for a plain, unmasked variable (such as NODE_ENV=test or PGPORT=5432) is not masked, so your logs read the same as on the cluster. Values shorter than four characters are never masked. Masking works on the exact text, so a secret that a step re-encodes (base64, URL escaping) is not recognised. Do not print secrets.

LOHA_TOKEN is never set. LOHA_RUN_ID is local- and eight hex digits, and LOHA_API_URL is the API address of your active context. Other platform variables stay unset unless the CLI serves them locally.

Output files​

Everything the run writes for you is in .loha/local-runs/<run id>/ at the root of your repository:

PathHolds
logs/One masked log per step, logs/<job>/<step>.log
artifacts/Archives saved by upload-artifact and artifacts-save steps, by artifact name
security/Scan reports from trivy-scan@v2, which are stored here and not sent to the server
scripts/The rendered step scripts as they were run

Each run prints Add .loha/local-runs/ to .gitignore; local run logs are written there. until git ignores the directory.

trivy-scan@v2 uploads its report to a collector. A local run starts a small collector on your machine that only stores the report, and the official script runs unchanged. On Docker Desktop and other VM-based engines this works out of the box. On a native Linux engine the collector listens on the engine's bridge gateway, so a rootless engine without a bridge gateway is not supported for this step.

Engine notes​

Engine VM resources​

On Docker Desktop, Colima, Rancher Desktop, OrbStack and a Podman machine, containers run inside a virtual machine with its own CPU and memory. Before it creates anything, the run compares what each job asks for with what the engine reports. A job needs its largest step container (steps run one at a time) plus all of its services (they run beside the steps). If that is more than the engine has, the run stops with

Job integration asks 8.25 CPUs / 9.25 GiB, but the engine has 4 CPUs / 7.76 GiB. Give the engine VM more resources or pass --no-limits.

Give the VM more CPUs and memory in its settings (for Colima, colima stop && colima start --cpu 8 --memory 12), or pass --no-limits to run without the plan's limits. The check is skipped with --no-limits and when the engine does not report its size.

Shared folders​

An engine VM sees only some folders of your machine. Colima and Lima share only your home folder by default. Step scripts no longer need any shared folder: they are delivered to the containers through a volume, so a repository outside your home folder runs normally. --mount-source does bind-mount your working tree, so it needs that folder to be shared; if it is not, the run stops before any step with The engine cannot see <path>. Move the repository under your home folder or share the folder with the engine VM, or run without --mount-source.

Pull policy​

Images follow the cluster's default pull policy. An image referenced by digest, or by a tag other than latest, is used as is when it is already on your machine, so an image you built or retagged locally runs without being pulled. An image with no tag or the tag latest is pulled on every run. If a pull fails (offline, registry down) and a local copy exists, the run continues with it and prints Could not pull <image>; using the local copy.

Volumes and Podman​

Run volumes are mounted without copy-up, so an image's files at a mount point never leak into a run volume and change what your steps see. This is verified on Docker only; Podman behaviour is not verified.

Cleanup and Ctrl-C​

Every container and volume the run creates is labelled io.loha.local-run=<run id> and named loha-<run id>-…. They are removed when the run ends, including after Ctrl-C. Nothing else on your machine is touched. If a removal fails, the CLI prints the command that finishes the job. With --keep the containers and every other volume are removed and only the source volume stays, so you can inspect it or reuse it; the CLI prints its name and the volume rm command.

What a run leaves behind​

  • The images it built, loha-local/<name>:<run id>; remove them with docker rmi.
  • The per-machine cache under loha/local-cache/ in your user cache directory.
  • With --keep, the source volume.
  • Its own output files in .loha/local-runs/<run id>/.

Machine-readable output​

With --json stdout carries only events, one JSON object per line: run_started, job_started, step_started, log, step_finished, job_finished, notice and a final run_finished with the status and exit code. Log lines are masked. Notes, such as the .gitignore hint, go to stderr, and the summary table is not printed. The exit code is the same as in text mode.

What is the same and what is different​

AspectLocal run compared with the cluster
Parsing, conditions, matrix, env precedence, task rendering, image policy, denied env namesSame, because the server renders the plan
Step images, scripts, user id, capabilities, working directorySame
CPU and memory limitsSame values, enforced by your engine, with no swap. Scheduling and node fit are not reproduced
SourceEmulated: your staged working tree instead of a clone of the pushed commit
ServicesEmulated: containers sharing one network namespace instead of pod sidecars
NetworkDifferent: your machine's network instead of build-pod egress rules, host aliases and mirrors
ArchitectureDifferent unless --platform is set; the CLI says so when your machine differs from the cluster
Secrets, stored variablesDifferent source: secrets come from you; plain variables follow your token's scopes
Cache (cache@v2)Emulated: a per-machine cache, exact key only, with no budget, rate limit or fallback
Job outputs and upload-artifactEmulated: the real scripts against a local server; archives in your run directory
Image buildsEmulated: your engine's builder (BuildKit or not), no push, plan build limits not applied
Approval gatesEmulated: you answer in the terminal, or pass --approve
Push, deploy, notify, signing, security gate, build recordNever locally

The summary prints the "different" rows that apply to the run you just did, each starting with ≠.

See also​