Docker provider
The Docker provider runs jobs inside containers during loom run --local. Each job uses the tools and filesystem supplied by its container image, plus the mounted workspace and configured variables. Matching an image tag alone does not guarantee the same result across machines.
When Loom uses Docker
Loom selects the Docker provider when the resolved job has a non-empty image: value (or an image.build block):
test:
stage: ci
target: linux
image: alpine:3.20
script:
- echo "running in container"
If image: is omitted, Loom uses the Host provider instead.
Prerequisites
- Docker installed on the local machine.
- Docker daemon running and reachable (
docker infoshould succeed). - The job's image must be pullable from a registry or already present locally.
Backend
The Docker provider communicates with the Docker daemon exclusively through the Docker Engine SDK (Go client library). There is no CLI shell-out backend — Loom talks directly to the Docker Engine API.
No backend selection flag is required. A reachable daemon is one prerequisite; image access, platform compatibility, and workspace permissions also affect whether a job can run.
Workspace mount modes
When the Docker provider creates a container, it mounts the isolated workspace into the container at /workspace. Loom supports two mount strategies:
| Mode | Description |
|---|---|
bind_mount | Bind-mounts the host workspace directory directly into the container. Changes inside the container are visible on the host immediately. |
ephemeral_volume (default) | Creates a Docker volume, seeds it with a copy of the workspace, and mounts that volume into the container. After execution, the volume is removed (no full workspace sync-back to host). |
Configuration
Set the mount mode with:
-
CLI flag:
--docker-workspace-mount <bind_mount|ephemeral_volume> -
Environment variable:
LOOM_DOCKER_WORKSPACE_MOUNT=<bind_mount|ephemeral_volume> -
Repo or user config:
runtime.docker.workspaceMountin.loom/config.ymlor~/.config/loom/config.yml
Precedence: flag > environment variable > repo config > user config > default (ephemeral_volume). See Precedence and merge rules.
# Default: ephemeral_volume
loom run --local --workflow .loom/workflow.yml
# Explicit bind_mount
loom run --local --docker-workspace-mount bind_mount --workflow .loom/workflow.yml
# Environment variable (flag overrides if both set)
LOOM_DOCKER_WORKSPACE_MOUNT=bind_mount loom run --local --workflow .loom/workflow.yml
When to choose each mode
| Use case | Recommended mode |
|---|---|
| Standard local development, fast iteration | bind_mount — skips the volume seed copy; changes are immediately visible |
| Jobs that modify many workspace files (risk of permission or ownership issues) | ephemeral_volume — isolates container writes from the host filesystem |
| Large workspaces where copy overhead is acceptable for stronger isolation | ephemeral_volume |
| CI-like fidelity where you want volume-based isolation | ephemeral_volume |
How ephemeral_volume works
- A Docker volume named
loom-job-workspace-<job-id>is created. - A helper container (
busybox:1.36.1) is created with only the volume mounted at/workspace. Loom streams a tar archive of the job's workspace snapshot (including.git) through the Docker API (CopyToContainer) into that mount, then starts the helper, whose only command verifies that/workspacematches the snapshot (non-empty for a populated snapshot, empty for an empty one). No host directory is bind-mounted for the seed, so VM-based daemons (Colima, Rancher Desktop, Docker Desktop) that do not share the host temp directory still receive the full snapshot. - The job container mounts the volume at
/workspace. - Cache restores, artifact-input overlays, and cache saves move files between a host staging directory and the volume through the Docker API: Loom streams the staged tree into a helper that mounts only the volume (
CopyToContainer), or streams the selected paths back out of it (CopyFromContainer). No host staging directory is bind-mounted, so caches keep their contents on VM-based daemons whateverTMPDIRis, and nothing is left behind inside the VM. - After the job completes, candidate artifact roots are exported to a pre-created staging directory and the volume is removed. Loom then filters exact matches into the persistent artifact destination and removes staging.
If any step in this process fails (volume creation, image pull for the helper, streaming the snapshot, or the emptiness check), the helper container and the volume are cleaned up and the job fails before its main container is created, with an error that names the snapshot path and the volume.
Runtime behavior
The Docker provider:
- Finds or pulls the image — uses the local image when present and pulls from the registry if missing. A mutable tag can refer to different image content on different machines; use a digest when you need to pin content.
- Mounts the workspace — mounts the snapshotted workspace into the container at
/workspaceusing the configured mount mode. - Sets working directory — the container's working directory is
/workspace. - Injects variables — passes job
variables:as container environment variables. Loom also setsLOOM_PROVIDER=dockerautomatically. - Executes scripts — runs
sh -c "<instrumented script>"inside the container (orsh -x -cwhen debug tracing is enabled viaLOOM_DEBUG_TRACE). The shell is not a login shell, so it does not source/etc/profile, and the image'sENVvalues, such asPATH, reach the script unchanged. - Captures output — records stdout, stderr, and exit code into structured runtime events.
- Publishes artifacts — bind-mount jobs match the isolated snapshot; ephemeral-volume jobs export from the execution volume. Both write to the live checkout's
.loom/.runtime/artifacts/<run_id>/jobs/<job-id>/.
Image builds
Jobs can build their own image by setting image as a mapping with name and a build block:
build-and-run:
stage: ci
target: linux
image:
name: my-org/custom-ci
build:
context: .
dockerfile: Dockerfile.ci
script:
- echo "running in the freshly built image"
Build block fields:
| Field | Required | Description |
|---|---|---|
context | Yes | Path to the build context, relative to the workflow file |
dockerfile | Yes | Path to the Dockerfile (must be within the build context) |
output | No | Docker BuildKit --output spec (e.g. type=docker,dest=/tmp/image.tar) |
If image.build is present and the script is empty, the SDK backend builds the image and skips container execution.
Sidecar services
When a job defines services, the Docker provider runs sidecar containers alongside the main job container.
Lifecycle
- Workspace preparation — for GitLab-format jobs, the assigned workspace is prepared before services start. Ephemeral-volume mode creates and seeds one job-owned volume; bind-mount mode uses the assigned snapshot directory.
- Network creation — a dedicated Docker network named
loom-job-net-<job-id>is created. - Service start — each service container is created and started on that network. If the service includes an
alias, it is registered as a network alias so the main container can reach it by hostname. - Readiness wait — the runtime gives each service up to 35 seconds to become ready. It first polls the container every 100 ms until it is
running, orhealthyif the image defines a Docker health check. Then, like GitLab Runner, it waits for TCP readiness:- It probes the port named by the service's
HEALTHCHECK_TCP_PORTvariable. This can be set on the service or inherited from job variables, and must be an integer from 1 to 65535. - Without that variable, it probes the image's exposed TCP ports and treats the service as ready once any one of them accepts a connection.
- The probe runs from a short-lived
busyboxhelper container on the job network, so it works when the Docker daemon runs in a VM such as Colima. - A service with neither setting is ready as soon as it is running (or healthy).
- If no probed port accepts a connection before the timeout, Loom records a warning on the service's
readiness_waitevent and starts the job anyway, as GitLab Runner does. The warning names the service, its aliases, and the ports. An invalidHEALTHCHECK_TCP_PORTvalue, a service that stops or reportsunhealthy, and a probe container that cannot be created or started still fail the job.
- It probes the port named by the service's
- Main container execution — the job's main container runs on the same network. It can reach services by image name or alias.
- Cleanup — after job execution and finalization, the job containers are removed, then the service containers and job network are removed, regardless of job outcome. For GitLab-format jobs, artifact export and workspace-volume removal happen after service cleanup. Startup failure and cancellation follow the same cleanup order.
GitLab workspace sharing
GitLab-format service containers receive the job's assigned workspace read-write
at /workspace, matching CI_PROJECT_DIR in the job. The same mount is used for
services, primary execution, and finalization. Services can read the seeded
snapshot before the job starts, and writes by either container are visible to the
other. Each job has its own workspace; process-user permissions still apply.
In ephemeral_volume mode, services share the seeded job-owned volume. In
bind_mount mode, they receive the same typed bind mount as the job. With a
consented docker:dind service and DOCKER_HOST=tcp://docker:2375, a job can run
docker run -v "$CI_PROJECT_DIR/x:/x" … and the nested container sees the job's
files under x. Privileged-service consent remains required, and Loom does not
mount the host Docker socket.
Example
integration:
stage: ci
target: linux
image: node:20-alpine
services:
- name: postgres:16
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_USER: runner
POSTGRES_PASSWORD: secret
script:
- npm run test:integration
Service limitations
- Privileged service containers require both a declared requirement and explicit provider consent. Programmatic callers set
docker.Provider.PrivilegedServiceAliasesto the uniquealiasvalues of services that require privileged mode and independently setAllowedPrivilegedServiceAliasesto the aliases they consent to privilege. A declared requirement without matching consent fails before Docker setup. With neither declaration, an ordinary service runs unprivileged; consent alone does not request privilege. Loom validates every selector in both lists before Docker setup, so an unknown, blank, or ambiguous alias fails without pulling images or creating networks or containers. Image names never grant privilege, the main job container remains unprivileged, and Loom does not bind the host Docker socket implicitly. Runtime and CLI wiring for these provider options are intentionally outside the current interface. - Readiness covers TCP only. When a service opens no port that can be probed (neither
HEALTHCHECK_TCP_PORTnor an exposed TCP port), or a port that is open does not mean the service is usable, add readiness polling to your script. Because a TCP readiness timeout is only a warning, a job can still start before its service is listening. - Workspace sharing requires a GitLab-format job. Native-format services do not receive a workspace mount.
- Schema-recognized but unsupported subkeys:
docker,kubernetes, andpull_policypass schema validation but are not yet implemented. Using them produces a validation error.
Job artifacts
Jobs can declare artifacts to publish specific files from the workspace that executed. Bind-mount mode reads the isolated snapshot. Ephemeral-volume mode exports candidate roots from the volume before removal, then applies the same include/exclude matcher in private staging. Both publish only selected files into the persistent artifact directory in the live checkout.
build:
stage: ci
target: linux
image: node:20-alpine
script:
- npm run build
artifacts:
paths:
- dist/
exclude:
- dist/**/*.map
when: on_success
Published artifacts are written to:
.loom/.runtime/artifacts/<run_id>/jobs/<job-id>/
When at least one file is published, an archive is also produced at artifacts.tar.gz in that job's artifact directory.
For the full artifacts schema, see Workflow syntax → artifacts.
Workspace mount and common gotchas
Mount path
The workspace is mounted at /workspace. All job scripts execute from that directory.
bind_mount on VM-based daemons
Before the first bind_mount job of a run, Loom writes a random marker into the
job's workspace snapshot and starts a busybox helper with the same bind to
check that the marker is visible. If it is not, the job fails before any image
pull or container start with
docker bind mount of "<path>" is not visible inside containers: the Docker daemon (<name>) runs in a VM that does not share this path; use runtime.docker.workspaceMount: ephemeral_volume or set TMPDIR under $HOME,
where <name> comes from the daemon's Info (for example colima). The probe
runs once per snapshot parent directory and the marker is removed afterwards.
Snapshots are created under the parent of os.TempDir() when that parent is
named tmp, T, or TemporaryItems (for example /private/tmp instead of a
per-user /var/folders/…/T directory on macOS), because VM daemons more often
share the stable parent than the per-user leaf. ephemeral_volume mode does not
depend on this at all.
bind_mount job and helper containers mount the snapshot with a typed bind
mount (--mount type=bind), so a path the daemon cannot see fails with
bind source path does not exist instead of being created empty inside the VM.
Cache and artifact-input staging never use a host bind in either mode; it
streams through the Docker API as described for ephemeral_volume.
File ownership
In bind_mount mode, container writes affect the isolated snapshot directly. A rootful container running as root can leave root-owned files there. If host tooling needs to modify those files afterward, you may need to fix permissions:
script:
- make build
- chown -R "$(stat -c '%u:%g' .)" /workspace/dist
In ephemeral_volume mode the seeded files are owned by 0:0 by default. When the job's user: is numeric (uid or uid:gid), the seed applies that ownership deterministically to every streamed file and directory, so the job can read and modify its checkout without extra chown steps; a missing gid defaults to 0. Named users such as postgres cannot be resolved against the image from the host, so their seeded files stay root-owned. Symlinks and file modes from the snapshot are preserved. The /workspace mount point itself keeps the ownership Docker gives the volume root (0:0, mode 755), so a non-root numeric user can change existing entries but needs a root step (for example chown "$(id -u)" /workspace in an earlier root-running job or image layer) before creating new top-level entries.
Line endings
If you see bash: $'\r': command not found, your scripts have Windows line endings (CRLF). Convert them to LF before running.
Security considerations
- Privileged mode is service-scoped and requires two independent inputs. Only a service uniquely selected by both the privilege-requirement list and the consent allowlist receives privileged mode. Main job containers, ordinary services, and services without matching consent stay unprivileged, and no Docker socket mount is added.
- No implicit host environment inheritance. Docker jobs do not inherit your local shell environment. Put required values in workflow/job
variables:so they are explicitly passed into the container. - Secret visibility. Any variable passed into a Docker job is potentially visible in logs if echoed. Avoid printing secrets; follow your organization's secret-handling policy.
- File-based secrets are copied through the Docker API into the created container at
/tmp/loom-secret-<name>-<ordinal>(mode0400, owned by the container user viaCopyUIDGID) before it starts, and the corresponding environment variable is rewritten to point to that path. No host secret path appears in the container's bind mounts, so VM-based daemons (Colima, Rancher Desktop, Docker Desktop) that do not share the host temp directory still receive the content. The bytes live in the container's writable layer until Loom force-removes the container at the end of the job. - For safe log-sharing practices, see What to share.
Confirming Docker provider ran
Inspect the resolved image with loom compile. For an executed command, print
LOOM_PROVIDER in a nonsecret diagnostic command and read its output with
loom logs <run_id> --json; Docker commands see LOOM_PROVIDER=docker.
loom logs <run_id> --state shows whether the job and commands ran. Provider
errors and image-build output appear in the adjacent events.jsonl. MCP clients
can select the job using loom_read_events with job_id.
For the full log structure, see the Runtime logs contract.
Troubleshooting
| Error / symptom | Cause | Fix |
|---|---|---|
docker daemon unavailable | Daemon not running | Start Docker Desktop / daemon, then retry |
| Job ran on host unexpectedly | Missing image: | Run loom compile and verify the resolved job has image: |
| Image pull fails | Wrong image name, auth issue, or rate limit | Confirm image name/tag and registry credentials |
| Permission denied on workspace files after run | Container created files as root | Fix ownership in script or use a non-root image |
docker workspace volume create failed | Volume creation failed (ephemeral_volume mode) | Verify Docker daemon is running and has permission to create volumes |
Workspace appears empty inside the job (No package.json found in /workspace, ls /workspace prints nothing) | Older Loom versions seeded the volume by bind-mounting the host snapshot under $TMPDIR, which VM-based daemons (Colima, Rancher Desktop) do not share | Upgrade Loom: the seed now streams the snapshot through the Docker API and fails fast with docker workspace seed verification failed if the volume stays empty, so no TMPDIR workaround is needed |
docker workspace seed failed: snapshot ... is not readable on the host | The per-job snapshot directory named in the error is missing or unreadable | Check the snapshot path and the permissions of the temp directory Loom uses for snapshots |
docker workspace seed verification failed | The daemon accepted the stream but /workspace in the volume does not match the snapshot (empty after a populated snapshot, or populated after an empty one) | Confirm the snapshot path in the error contains files; check the daemon logs for the PUT /containers/<id>/archive request |
docker bind mount of "<path>" is not visible inside containers | The daemon (for example Colima) runs in a VM that does not share the snapshot directory | Keep the default runtime.docker.workspaceMount: ephemeral_volume, or set TMPDIR under $HOME so the VM shares the path; see bind_mount on VM-based daemons |
Cache saves report cache save complete but candidate archives are about 16 bytes, restores are empty, or the VM disk fills with loom-cache-save-* directories | Older Loom versions bind-mounted the cache staging directory under $TMPDIR, which VM-based daemons (Colima, Lima) do not share; Docker silently created it inside the VM | Upgrade Loom: cache staging now streams through the Docker API. Remove leftovers inside the VM, for example colima ssh -- sudo find /var/folders /tmp -maxdepth 6 -name 'loom-cache-*' -prune -exec rm -rf {} + |
bind source path does not exist | A bind_mount job's snapshot directory is not visible to the daemon | Keep the default ephemeral_volume mode, or set TMPDIR under $HOME; see bind_mount on VM-based daemons |
docker workspace volume cleanup failed | Volume removal failed during cleanup | Check Docker daemon health and volume permissions |
When a cache import or export, artifact export, or workspace seed helper
container exits non-zero, the node error and recorded diagnostic messages include the last lines
of that helper's script output after helper output:, so a permission error or
a missing path is visible without inspecting the daemon.
Limitations
- Docker provider is selected per job based on the resolved
image:value. - Local runtime behavior may differ from future remote execution modes.