Docker and Compose user blocks

Stackie can run a Compose service as a canonical user block when the service selects a catalog-mapped runtime image and bind-mounts its source at that block’s declared code mount. The Compose file remains familiar, while Stackie preserves the block’s version, command, source, and sandbox policy.

How Stackie decides

mocker.images in a block definition is the only authority that can map a Docker or Compose image to a Stackie block. Image names, registry aliases, tags, digests, commands, labels, annotations, runtime handlers, and bind mounts cannot create a block mapping on their own.

one matchno matchmultiple matchesnoyesone valid bindnoneambiguous or invalid

compose.yml

Docker Compose path resolution

Docker Engine API

Mocker Compose adapter

mocker.images match?

runtime metadata?

Image-not-found response

Actionable rejection

Ordinary mapped block workload

matching host bind at code_mount?

Canonical Stackie user block

stackied + block sandbox policy

Stackie flowchart: #stackie-mermaid-0{font-family:Inter Variable,Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:16px;fill:#f8fafc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#stackie-mermaid-0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#stackie-mermaid-0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#stackie-mermaid-0 .error-icon{fill:#111827;}#stackie-mermaid-0 .error-text{fill:#f8fafc;stroke:#f8fafc;}#stackie-mermaid-0 .edge-thickness-normal{stroke-width:1px;}#stackie-mermaid-0 .edge-thickness-thick{stroke-width:3.5px;}#stackie-mermaid-0 .edge-pattern-solid{stroke-dasharray:0;}#stackie-mermaid-0 .edge-thickness-invisible{stroke-width:0;fill:none;}#stackie-mermaid-0 .edge-pattern-dashed{stroke-dasharray:3;}#stackie-mermaid-0 .edge-pattern-dotted{stroke-dasharray:2;}#stackie-mermaid-0 .marker{fill:#facc15;stroke:#facc15;}#stackie-mermaid-0 .marker.cross{stroke:#facc15;}#stackie-mermaid-0 svg{font-family:Inter Variable,Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:16px;}#stackie-mermaid-0 p{margin:0;}#stackie-mermaid-0 .label{font-family:Inter Variable,Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#f8fafc;}#stackie-mermaid-0 .cluster-label text{fill:#f8fafc;}#stackie-mermaid-0 .cluster-label span{color:#f8fafc;}#stackie-mermaid-0 .cluster-label span p{background-color:transparent;}#stackie-mermaid-0 .label text,#stackie-mermaid-0 span{fill:#f8fafc;color:#f8fafc;}#stackie-mermaid-0 .node rect,#stackie-mermaid-0 .node circle,#stackie-mermaid-0 .node ellipse,#stackie-mermaid-0 .node polygon,#stackie-mermaid-0 .node path{fill:#1f2937;stroke:#facc15;stroke-width:1px;}#stackie-mermaid-0 .rough-node .label text,#stackie-mermaid-0 .node .label text,#stackie-mermaid-0 .image-shape .label,#stackie-mermaid-0 .icon-shape .label{text-anchor:middle;}#stackie-mermaid-0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#stackie-mermaid-0 .rough-node .label,#stackie-mermaid-0 .node .label,#stackie-mermaid-0 .image-shape .label,#stackie-mermaid-0 .icon-shape .label{text-align:center;}#stackie-mermaid-0 .node.clickable{cursor:pointer;}#stackie-mermaid-0 .root .anchor path{fill:#facc15!important;stroke-width:0;stroke:#facc15;}#stackie-mermaid-0 .arrowheadPath{fill:rgba(255, 255, 255, 0);}#stackie-mermaid-0 .edgePath .path{stroke:#facc15;stroke-width:1px;}#stackie-mermaid-0 .flowchart-link{stroke:#facc15;fill:none;}#stackie-mermaid-0 .edgeLabel{background-color:#171717;text-align:center;}#stackie-mermaid-0 .edgeLabel p{background-color:#171717;}#stackie-mermaid-0 .edgeLabel rect{opacity:0.5;background-color:#171717;fill:#171717;}#stackie-mermaid-0 .labelBkg{background-color:rgba(23, 23, 23, 0.5);}#stackie-mermaid-0 .cluster rect{fill:#111827;stroke:#2dd4bf;stroke-width:1px;}#stackie-mermaid-0 .cluster text{fill:#f8fafc;}#stackie-mermaid-0 .cluster span{color:#f8fafc;}#stackie-mermaid-0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Inter Variable,Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:12px;background:#111827;border:1px solid #2dd4bf;border-radius:2px;pointer-events:none;z-index:100;}#stackie-mermaid-0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#f8fafc;}#stackie-mermaid-0 rect.text{fill:none;stroke-width:0;}#stackie-mermaid-0 .icon-shape,#stackie-mermaid-0 .image-shape{background-color:#171717;text-align:center;}#stackie-mermaid-0 .icon-shape p,#stackie-mermaid-0 .image-shape p{background-color:#171717;padding:2px;}#stackie-mermaid-0 .icon-shape .label rect,#stackie-mermaid-0 .image-shape .label rect{opacity:0.5;background-color:#171717;fill:#171717;}#stackie-mermaid-0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#stackie-mermaid-0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#stackie-mermaid-0 .node .neo-node{stroke:#facc15;}#stackie-mermaid-0 [data-look="neo"].node rect,#stackie-mermaid-0 [data-look="neo"].cluster rect,#stackie-mermaid-0 [data-look="neo"].node polygon{stroke:url(#stackie-mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#stackie-mermaid-0 [data-look="neo"].swimlane.cluster rect{filter:none;}#stackie-mermaid-0 [data-look="neo"].node path{stroke:url(#stackie-mermaid-0-gradient);stroke-width:1px;}#stackie-mermaid-0 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#stackie-mermaid-0 [data-look="neo"].node .neo-line path{stroke:#facc15;filter:none;}#stackie-mermaid-0 [data-look="neo"].node circle{stroke:url(#stackie-mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#stackie-mermaid-0 [data-look="neo"].node circle .state-start{fill:#000000;}#stackie-mermaid-0 [data-look="neo"].icon-shape .icon{fill:url(#stackie-mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#stackie-mermaid-0 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#stackie-mermaid-0-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#stackie-mermaid-0 :root{--mermaid-font-family:Inter Variable,Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;}, one, match, multiple, matches. Use horizontal scrolling when the full diagram is wider than the visible frame. Generated from Stackie public documentation source.

In text: Mocker first resolves the image against canonical block metadata. A mapped runtime can become a user block only when exactly one safe host bind targets the catalog-declared code mount. A mapped runtime without that bind, or a mapped non-runtime service, remains an ordinary block workload. No match stays an OCI workload in the typed compatibility result and never becomes a catalog block. The current Docker container-create path returns image-not-found for that unmatched image, so an unmatched Compose service is not executable there. Ambiguity or an invalid matching source bind is rejected instead of guessed.

Compose inputResultCommand and sandbox behavior
node:20 or the catalog alias library/node:20 + exactly one matching source bindThe same canonical stackie.node.sdk@20 user block using the host sourceAliases listed by that block’s mocker.images metadata share block identity; user-provided aliases cannot create another mapping
Mapped image + exactly one matching source bindCanonical Stackie user block using the host sourceCatalog default command, or a structured override whose executable is allow-listed; the normal block sandbox remains enabled
Mapped image + no bind at code_mountOrdinary mapped block workload, not a user blockThe catalog block follows the existing Docker-compatible block path
Mapped runtime image + a named volume at code_mountOrdinary mapped block workload, not a user blockNamed volumes are not host source binds and cannot supply first-party source
Mapped runtime image + only an unrelated host bindOrdinary mapped block workload, not a user blockA bind targeting the working directory or another path does not substitute for the declared code_mount
Mapped runtime image + one matching source bind + unrelated bindsCanonical Stackie user block using the matching host sourceUnrelated mounts do not make source selection ambiguous and remain ordinary Docker-compatible mounts
Mapped non-runtime service image, with or without bindsOrdinary mapped block workload, never a user blockService blocks do not gain runtime metadata from a mount, command, label, or working directory
Mapped image + multiple binds at code_mountRejected as ambiguousNo bind is selected by order
Mapped image + matching bind + disallowed executableRejected by command policyShell fragments, paths, and aliases do not bypass the executable allow-list
Mapped image + matching bind + sandbox-related labels or optionsCanonical user blockThe catalog-owned sandbox policy wins; a source bind never disables sandboxing
Unmapped image, with or without a source bindDocker container-create returns image-not-foundThe compatibility resolver preserves a typed OCI identity for supported consumers such as CRI, but current Docker/Compose creation does not start it
More than one mocker.images catalog entry matchesRejected as an ambiguous catalog matchStackie never picks the first entry

Host sources are canonicalised after resolving symlinks. Missing paths, files where a directory is required, traversal outside the Compose project and allowed roots, and symlink escapes fail before workload admission. Environment variables and published ports remain normal Compose inputs, but they do not change image-to-block authority.

Complete Node example

Create compose.yml:

# Canonical Stackie user-block example: the runtime is catalog-mapped and the
# command executes directly from the live ./src bind without disabling sandboxing.
services:
  app:
    image: node:20
    working_dir: /app
    volumes:
      - ./src:/app
    command: ["node", "index.js"]
    environment:
      PORT: "3000"
      STACKIE_EXAMPLE_MESSAGE: "Hello from the Node user block"
    ports:
      - "3000:3000"

Create src/index.js:

// This dependency-free server executes directly from the Compose source bind.
const { readFileSync } = require("node:fs");
const http = require("node:http");
const { join } = require("node:path");

const port = Number.parseInt(process.env.PORT ?? "3000", 10);
const messagePath = join(__dirname, "message.txt");
const environmentMessage =
  process.env.STACKIE_EXAMPLE_MESSAGE ?? "Hello from the Node user block";

http
  .createServer((_request, response) => {
    const message = readFileSync(messagePath, "utf8").trim();
    response.writeHead(200, { "content-type": "application/json" });
    response.end(
      JSON.stringify({
        runtime: "node",
        message,
        environment_message: environmentMessage,
      }),
    );
  })
  .listen(port, "0.0.0.0", () => {
    console.log(`Node user block listening on ${port}`);
  });

Create src/message.txt:

Hello from the Node user block

Start it, edit the mounted file after the process is running, and verify that the same process observes the change:

docker --context stackie compose up -d
curl --fail http://127.0.0.1:3000/
printf '%s\n' 'Changed without rebuilding the image' > src/message.txt
curl --fail http://127.0.0.1:3000/
docker --context stackie compose down

No build: section or image rebuild is involved. The running Node workload reads the edited file from the admitted host source bind.

Complete Python example

Create compose.yml:

# Canonical Stackie user-block example: the runtime is catalog-mapped and the
# command executes directly from the live ./src bind without disabling sandboxing.
services:
  app:
    image: python:3.13
    working_dir: /app
    volumes:
      - ./src:/app
    command: ["python", "app.py"]
    environment:
      PORT: "8000"
      STACKIE_EXAMPLE_MESSAGE: "Hello from the Python user block"
    ports:
      - "8000:8000"

Create src/app.py:

"""Dependency-free HTTP server executed directly from the Compose source bind."""

import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path


MESSAGE_PATH = Path(__file__).with_name("message.txt")
ENVIRONMENT_MESSAGE = os.environ.get(
    "STACKIE_EXAMPLE_MESSAGE", "Hello from the Python user block"
)


class ExampleHandler(BaseHTTPRequestHandler):
    """Return one deterministic JSON document for every request."""

    def do_GET(self) -> None:
        """Write the example response."""
        payload = json.dumps(
            {
                "runtime": "python",
                "message": MESSAGE_PATH.read_text(encoding="utf-8").strip(),
                "environment_message": ENVIRONMENT_MESSAGE,
            }
        ).encode("utf-8")
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, message: str, *args: object) -> None:
        """Keep the executable example output deterministic."""


if __name__ == "__main__":
    port = int(os.environ.get("PORT", "8000"))
    print(f"Python user block listening on {port}", flush=True)
    ThreadingHTTPServer(("0.0.0.0", port), ExampleHandler).serve_forever()

Create src/message.txt:

Hello from the Python user block

Start it, edit the mounted file after the process is running, and verify that the same process observes the change:

docker --context stackie compose up -d
curl --fail http://127.0.0.1:8000/
printf '%s\n' 'Changed without rebuilding the image' > src/message.txt
curl --fail http://127.0.0.1:8000/
docker --context stackie compose down

As in the Node example, the running workload reads the edit from the host bind and no image build occurs.

Point Docker tools at Stackie

On Linux and macOS, create a Docker context for the Stackie socket:

docker context create stackie --description "Stackie local daemon" --docker "host=unix://$HOME/.stackie/stackied.sock"
docker context use stackie
docker --context stackie ps

On Windows:

docker context create stackie --description "Stackie local daemon" --docker "host=npipe:////./pipe/stackie_daemon"
docker context use stackie
docker --context stackie ps

Tools without Docker-context support can use the same endpoint through DOCKER_HOST, which remains fully supported:

export DOCKER_HOST="unix://$HOME/.stackie/stackied.sock"
docker ps
$env:DOCKER_HOST = "npipe:////./pipe/stackie_daemon"
docker ps

Docker Compose project labels continue to group service lifecycle, logs, and removal operations. Stackie captures stdout and stderr for stackie logs, dashboard logs, MCP log tools, and Docker-compatible log responses. Compose logging: driver settings do not replace that local capture.

Volume syntax and source paths

Short bind syntax is convenient for local projects:

volumes:
  - ./src:/app

Structured long syntax makes the bind type and read-only policy explicit:

volumes:
  - type: bind
    source: ./src
    target: /app
    read_only: true

Both forms identify the same source when Docker Compose resolves ./src against the Compose project directory and sends Stackie the resulting host path. The source must resolve to an available directory within the admitted project or configured source roots; traversal, a missing path, a file where a directory is required, and a symlink escape are rejected. Use the host’s native path syntax for source (including a Windows drive path when the project is on Windows), but keep the container target in POSIX form. The target must exactly match the runtime block’s declared code mount, /app in these examples. read_only: true still permits classification while preventing the workload from writing the mounted source.

Named volumes are not source binds. A named volume such as app-data:/app, or a bind targeting a different container directory, keeps the service on the ordinary mapped-block path.

Compose fields and lifecycle

Compose fieldStackie behavior
commandPreserved as a structured argument vector and checked against the resolved block’s executable policy
entrypointCombined with command when it invokes the native block executable; image-internal wrapper entrypoints are not emulated
working_dirPreserved for the workload; it does not replace the block’s declared code mount when selecting source
environmentPreserved as workload environment values, subject to the normal sandbox and secret-handling policy
portsPreserved as requested port mappings and checked by normal admission
labels and project identityCompose project/service labels continue to group inspect, logs, stop, and removal operations
depends_on and service orderDocker Compose owns dependency planning; Stackie honors the resulting create/start/stop calls but does not invent block dependencies
stop, restart, and removeContinue through the Docker-compatible lifecycle while Stackie retains canonical block identity and log ownership

Host edits become visible through the admitted bind immediately at the filesystem boundary. That does not automatically restart an application or reload a module: use a watcher in command, or an application that rereads files as the examples do. Classification also is not authorization. Bind-mounted source remains user input, its path is validated, and the catalog-owned sandbox and filesystem policy remain active.

Limitations

This workflow supports a catalog-mapped runtime image plus a host source bind. A Compose build: or Dockerfile build is a separate compatibility surface and is not required to create this user block. The current Docker/Compose create path does not execute arbitrary unmatched OCI images; it reports image-not-found. Privileged mode, host-kernel/container-runtime features, and labels that try to disable Stackie’s sandbox are not accepted as user-block capabilities. This is a local-development workflow, not a claim that a Compose service is a production container deployment.

Troubleshooting

  • The service is an ordinary block: confirm the image maps through the block’s mocker.images, the block declares runtime metadata, and exactly one host bind targets its declared code mount. A wrong container target does not identify application source.
  • A named volume does not become source: change it to type: bind (or short host-bind syntax) when the directory is user-owned source.
  • The source path is missing or unexpected: resolve relative paths from the Compose project directory, then check that the final host directory is available to Stackie and does not escape through a symlink.
  • Startup reports an image error: use an alias declared by the owning block’s mocker.images; similar-looking or user-invented aliases do not create a mapping.
  • The command fails policy: make entrypoint, command, and working_dir agree with the resolved native block executable rather than relying on an image-internal wrapper script.
  • Edits are visible but behavior does not change: add the application’s normal hot-reload watcher or issue a Compose restart.
  • Inspect the decision: from either published example directory, use docker --context stackie inspect "$(docker --context stackie compose ps -q app)" and docker --context stackie compose logs app, or use the Stackie dashboard workload view, to see the resolved project/service identity, canonical block, command, source, and lifecycle.

Ownership and future plugin migration

Mocker owns Docker/Compose parsing and image-to-block translation. Bridge carries only transport-neutral workload evidence, and the Kubernetes and CRI plugins consume Mocker’s ImageCompatibility seam. A future Mocker plugin can provide the same capability through the generic plugin host without relocating or duplicating Docker semantics.