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.
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 input | Result | Command and sandbox behavior |
|---|---|---|
node:20 or the catalog alias library/node:20 + exactly one matching source bind | The same canonical stackie.node.sdk@20 user block using the host source | Aliases listed by that block’s mocker.images metadata share block identity; user-provided aliases cannot create another mapping |
| Mapped image + exactly one matching source bind | Canonical Stackie user block using the host source | Catalog default command, or a structured override whose executable is allow-listed; the normal block sandbox remains enabled |
Mapped image + no bind at code_mount | Ordinary mapped block workload, not a user block | The catalog block follows the existing Docker-compatible block path |
Mapped runtime image + a named volume at code_mount | Ordinary mapped block workload, not a user block | Named volumes are not host source binds and cannot supply first-party source |
| Mapped runtime image + only an unrelated host bind | Ordinary mapped block workload, not a user block | A bind targeting the working directory or another path does not substitute for the declared code_mount |
| Mapped runtime image + one matching source bind + unrelated binds | Canonical Stackie user block using the matching host source | Unrelated mounts do not make source selection ambiguous and remain ordinary Docker-compatible mounts |
| Mapped non-runtime service image, with or without binds | Ordinary mapped block workload, never a user block | Service blocks do not gain runtime metadata from a mount, command, label, or working directory |
Mapped image + multiple binds at code_mount | Rejected as ambiguous | No bind is selected by order |
| Mapped image + matching bind + disallowed executable | Rejected by command policy | Shell fragments, paths, and aliases do not bypass the executable allow-list |
| Mapped image + matching bind + sandbox-related labels or options | Canonical user block | The catalog-owned sandbox policy wins; a source bind never disables sandboxing |
| Unmapped image, with or without a source bind | Docker container-create returns image-not-found | The 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 matches | Rejected as an ambiguous catalog match | Stackie 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 field | Stackie behavior |
|---|---|
command | Preserved as a structured argument vector and checked against the resolved block’s executable policy |
entrypoint | Combined with command when it invokes the native block executable; image-internal wrapper entrypoints are not emulated |
working_dir | Preserved for the workload; it does not replace the block’s declared code mount when selecting source |
environment | Preserved as workload environment values, subject to the normal sandbox and secret-handling policy |
ports | Preserved as requested port mappings and checked by normal admission |
| labels and project identity | Compose project/service labels continue to group inspect, logs, stop, and removal operations |
depends_on and service order | Docker Compose owns dependency planning; Stackie honors the resulting create/start/stop calls but does not invent block dependencies |
| stop, restart, and remove | Continue 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, andworking_diragree 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)"anddocker --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.