Skip to main content

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 info should 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:

ModeDescription
bind_mountBind-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.workspaceMount in .loom/config.yml or ~/.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 caseRecommended mode
Standard local development, fast iterationbind_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 isolationephemeral_volume
CI-like fidelity where you want volume-based isolationephemeral_volume

How ephemeral_volume works​

  1. A Docker volume named loom-job-workspace-<job-id> is created.
  2. 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 /workspace matches 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.
  3. The job container mounts the volume at /workspace.
  4. 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 whatever TMPDIR is, and nothing is left behind inside the VM.
  5. 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:

  1. 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.
  2. Mounts the workspace — mounts the snapshotted workspace into the container at /workspace using the configured mount mode.
  3. Sets working directory — the container's working directory is /workspace.
  4. Injects variables — passes job variables: as container environment variables. Loom also sets LOOM_PROVIDER=docker automatically.
  5. Executes scripts — runs sh -c "<instrumented script>" inside the container (or sh -x -c when debug tracing is enabled via LOOM_DEBUG_TRACE). The shell is not a login shell, so it does not source /etc/profile, and the image's ENV values, such as PATH, reach the script unchanged.
  6. Captures output — records stdout, stderr, and exit code into structured runtime events.
  7. 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:

FieldRequiredDescription
contextYesPath to the build context, relative to the workflow file
dockerfileYesPath to the Dockerfile (must be within the build context)
outputNoDocker 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​

  1. 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.
  2. Network creation — a dedicated Docker network named loom-job-net-<job-id> is created.
  3. 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.
  4. 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, or healthy if 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_PORT variable. 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 busybox helper 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_wait event and starts the job anyway, as GitLab Runner does. The warning names the service, its aliases, and the ports. An invalid HEALTHCHECK_TCP_PORT value, a service that stops or reports unhealthy, and a probe container that cannot be created or started still fail the job.
  5. Main container execution — the job's main container runs on the same network. It can reach services by image name or alias.
  6. 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.PrivilegedServiceAliases to the unique alias values of services that require privileged mode and independently set AllowedPrivilegedServiceAliases to 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_PORT nor 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, and pull_policy pass 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> (mode 0400, owned by the container user via CopyUIDGID) 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 / symptomCauseFix
docker daemon unavailableDaemon not runningStart Docker Desktop / daemon, then retry
Job ran on host unexpectedlyMissing image:Run loom compile and verify the resolved job has image:
Image pull failsWrong image name, auth issue, or rate limitConfirm image name/tag and registry credentials
Permission denied on workspace files after runContainer created files as rootFix ownership in script or use a non-root image
docker workspace volume create failedVolume 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 shareUpgrade 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 hostThe per-job snapshot directory named in the error is missing or unreadableCheck the snapshot path and the permissions of the temp directory Loom uses for snapshots
docker workspace seed verification failedThe 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 containersThe daemon (for example Colima) runs in a VM that does not share the snapshot directoryKeep 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-* directoriesOlder 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 VMUpgrade 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 existA bind_mount job's snapshot directory is not visible to the daemonKeep the default ephemeral_volume mode, or set TMPDIR under $HOME; see bind_mount on VM-based daemons
docker workspace volume cleanup failedVolume removal failed during cleanupCheck 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.