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.
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.
dockeris the default. - Podman works on a best-effort basis. Choose it with
--engine podmanorLOHA_CONTAINER_ENGINE=podman(the flag wins). - Without an engine the command exits with code
3and saysNo 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.
| Flag | Purpose |
|---|---|
--local | Required. loha run without it prints a hint to use loha deploy and exits with 2 |
--file, --branch, --event, --sha, --repo, --trigger, --var KEY=VALUE | Plan 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|podman | Container engine (default: LOHA_CONTAINER_ENGINE, else docker) |
--job <id> | Run only this job and the jobs it needs, repeatable |
--parallel N | Run up to N ready jobs at once, 1 to 16 (default 1). Cannot be combined with --mount-source (see Parallel jobs) |
--matrix KEY=VALUE | Run 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 |
--approve | Accept every approval gate without asking (see Approvals) |
--secret NAME | Give 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-source | Bind-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-only | Take the pipeline file and the sources from HEAD and ignore local changes. Cannot be combined with --mount-source |
--no-limits | Do not apply the plan's CPU and memory limits |
--platform linux/amd64 | Run containers for this platform (emulated when it differs from your machine) |
--keep | Keep the source volume after the run and print its name |
--json | Print 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
| Code | Meaning |
|---|---|
0 | Every enabled step passed |
1 | A 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) |
2 | The 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 |
3 | No 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:
runsteps run as written: the same image and script, in a container started with the plan's user,--cap-drop ALLplus 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.emusteps are replaced by a local equivalent. The checkout is replaced by staging your sources (see Source); the cache taskcache@v2,upload-artifactand the job outputs thatartifacts-saveandartifacts-restorecarry 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).skipsteps do not run on your machine: anything that pushes, deploys, notifies or signs, and the managed security gate. Agit-cloneof this repository is recognised by itsgroup/projectpath, so an SSH host alias or a mirror URL still counts. A clone of a different repository is skipped withClones another repository; not available locally., and a note names both URLs (without credentials). When nothing else staged your sources, a second note saysNothing 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/Cacheson macOS,~/.cacheon 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'simagewithout its tag, lower-cased and cleaned toa-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 candocker runit afterwards, and delete it withdocker rmiwhen you are done. - The context's
.dockerignoreapplies, 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..dockerignoreand the Dockerfile are always sent. A line the CLI cannot apply exactly (such as**inside a name) is reported asCould not apply .dockerignore line <n> locally; the cluster builder may exclude more. - With a buildx builder whose driver is not
docker(for exampledocker-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_PROXYand 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 usesRUN --mountor 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
--stepand--fromneed--reuse <volume>, a volume an earlier--keeprun 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 noio.loha.repo-keylabel and cannot be reused; run again with--keepto 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/shin the step's container, with its image, user, environment, mounts and working directory, instead of running its script. The step is reportedskipped: shelland 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 withexitor Ctrl-D. A SIGINT sent tolohafrom elsewhere (kill -INT) still ends the shell and cleans up. If a killed shell leaves your terminal in raw mode, typereset. A step that does not run in a container locally (anemustep) has no shell to open.- The shell session is raw: what you type and what it prints is not masked and not
logged, so
envprints secret values in clear. --shellneeds a terminal on both stdin and stdout, and cannot be combined with--json,--step,--fromor--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 -Awould commit (tracked and untracked, not ignored), plus a shallow.gitwhoseHEADis yourHEAD, sogit rev-parse HEADworks and your uncommitted changes show up as modifications. Copying, rather than mounting, keeps a stale localnode_modulesor build output from hiding a failure that the cluster would show. --committed-only:HEADonly, as the cluster would see it after you push the current commit. The pipeline file is also read fromHEAD.--mount-source: your working tree is mounted directly. The CLI printsBuild 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 NAMEreads the value from the environment variableNAMEof your shell. If it is not set the command stops with--secret NAME: environment variable NAME is not set.--env-file <path>readsKEY=VALUElines. It supplies secrets and, when your token lacksvariables: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=VALUEper 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
--secretvalue; - every plain variable the plan marks
masked, in steps and services; - an
--env-filevalue whose name a step uses as a secret, or that the plan marksmaskedanywhere, 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:
| Path | Holds |
|---|---|
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 withdocker 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
| Aspect | Local run compared with the cluster |
|---|---|
| Parsing, conditions, matrix, env precedence, task rendering, image policy, denied env names | Same, because the server renders the plan |
| Step images, scripts, user id, capabilities, working directory | Same |
| CPU and memory limits | Same values, enforced by your engine, with no swap. Scheduling and node fit are not reproduced |
| Source | Emulated: your staged working tree instead of a clone of the pushed commit |
| Services | Emulated: containers sharing one network namespace instead of pod sidecars |
| Network | Different: your machine's network instead of build-pod egress rules, host aliases and mirrors |
| Architecture | Different unless --platform is set; the CLI says so when your machine differs from the cluster |
| Secrets, stored variables | Different 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-artifact | Emulated: the real scripts against a local server; archives in your run directory |
| Image builds | Emulated: your engine's builder (BuildKit or not), no push, plan build limits not applied |
| Approval gates | Emulated: you answer in the terminal, or pass --approve |
| Push, deploy, notify, signing, security gate, build record | Never locally |
The summary prints the "different" rows that apply to the run you just did, each starting
with ≠.