Custom Image Build Workflow¶
Build Docker images from stack-local Dockerfiles via Komodo Build, push to private registry, reference in compose.
Pattern established with nanobot stack. Reuse for any stack needing custom images.
When to Use¶
- Stack needs a Dockerfile (app not on Docker Hub, custom pip install, extra OS packages)
- Stack needs files baked into image (static assets, config templates, scripts)
- Upstream image exists but needs modification (extra tools, env defaults)
When NOT to use: upstream image on Docker Hub / GHCR works as-is → use image: directly in compose.yaml.
Architecture¶
stacks/{category}/{service}/
├── Dockerfile # Build definition
└── compose.yaml # References built image
komodo/builds/{service}.toml # Build resource (triggers Komodo builder)
komodo/stacks/{category}/{service}.toml # Stack resource (deploys built image)
Three resources involved:
1. Build — builds Dockerfile, tags + pushes to registry
2. Stack — deploys compose.yaml referencing the built image
3. Resource Sync — builds sync picks up komodo/builds/*.toml changes
Step-by-Step¶
1. Create Dockerfile in stack directory¶
stacks/{category}/{service}/Dockerfile:
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends git curl && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir your-package
RUN useradd -m -u 1000 appuser
USER appuser
WORKDIR /home/appuser
EXPOSE 8080
ENTRYPOINT ["your-app"]
CMD ["--help"]
2. Create Build resource¶
komodo/builds/{service}.toml:
[[build]]
name = "{service}"
[build.config]
builder_id = "local"
linked_repo = "docker-compose"
branch = "main"
build_path = "./stacks/{category}/{service}/"
dockerfile_path = "Dockerfile"
auto_increment_version = true
include_latest_tag = true
include_version_tags = true
include_commit_tag = true
image_registry = [{domain = "git.minhluc.info", account = "lucndm"}]
Key fields:
- builder_id = "local" — uses the Komodo builder on development server
- build_path — relative to repo root, points to directory containing Dockerfile
- image_registry — pushes to git.minhluc.info/lucndm/{service}:latest
3. Reference built image in compose.yaml¶
4. Create Stack resource (standard)¶
komodo/stacks/{category}/{service}.toml:
[[stack]]
name = "{service}"
deploy = true
tags = ["app:{name}", "server:unraid", "category:{category}"]
[stack.config]
server = "unraid"
poll_for_updates = false
auto_update = true
destroy_before_deploy = true
auto_update_all_services = false
linked_repo = "docker-compose"
run_directory = "./stacks/{category}/{service}/"
file_paths = ["compose.yaml"]
5. Deploy sequence¶
# First time: sync builds + stack, build image, deploy
git add -A && git commit -m "feat({service}): add stack with custom image" && git push
echo "" | km x run-sync builds
echo "" | km x run-sync {category}
echo "" | km x run-build {service} # Build image (2-5 min)
echo "" | km x deploy-stack {service} # Deploy stack
# Subsequent changes: use sync-and-deploy procedure (includes build stage)
git add -A && git commit -m "chore({service}): update" && git push
km x -y run-procedure sync-and-deploy # Sync → Build all → Deploy changed
# Or manual if only one stack changed:
echo "" | km x run-build {service}
echo "" | km x deploy-stack {service}
6. Update sequence (compose-only, no Dockerfile change)¶
# compose.yaml change only — no rebuild needed
git add -A && git commit -m "chore({service}): update config" && git push
echo "" | km x deploy-stack {service}
Pitfalls¶
file_paths MUST NOT include Dockerfile¶
Komodo passes ALL file_paths as -f flags to docker compose. Dockerfile is not YAML — including it causes "top-level object must be a mapping" error.
The Build resource reads Dockerfile directly via build_path + dockerfile_path. Stack resource only needs compose.yaml.
Image registry authentication¶
Builder needs registry credentials. The local builder on development server has credentials for git.minhluc.info configured in Komodo builder settings.
If push fails with "unauthorized", check builder credentials in Komodo UI → Builders → local.
destroy_before_deploy + pull timing¶
With destroy_before_deploy = true, Komodo destroys old container before pulling new image. If image tag hasn't changed (:latest already exists), Docker uses cached layer. Use version tags or auto_increment_version = true to force fresh pulls.
Build timeout¶
Large images (pip install from source) can take 5-10 min. Komodo default build timeout is usually sufficient, but monitor via:
UID/GID for unraid¶
Unraid file ownership is nobody (99:100). If container writes to volumes:
RUN useradd -m -u 1000 appuser # Use 1000 for non-volume containers
# OR
RUN useradd -m -u 99 appuser # Use 99 for containers writing to unraid volumes
In compose.yaml:
Full Example: nanobot¶
Files¶
stacks/llm/nanobot/
├── Dockerfile
└── compose.yaml
komodo/builds/nanobot.toml
komodo/stacks/llm/nanobot.toml
Dockerfile¶
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends git curl && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "nanobot-ai>=0.2.0"
RUN useradd -m -u 1000 nanobot
USER nanobot
WORKDIR /home/nanobot
EXPOSE 18790
ENTRYPOINT ["nanobot"]
CMD ["status"]
Build TOML¶
[[build]]
name = "nanobot"
[build.config]
builder_id = "local"
linked_repo = "docker-compose"
branch = "main"
build_path = "./stacks/llm/nanobot/"
dockerfile_path = "Dockerfile"
auto_increment_version = true
include_latest_tag = true
include_version_tags = true
include_commit_tag = true
image_registry = [{domain = "git.minhluc.info", account = "lucndm"}]
Stack TOML¶
[[stack]]
name = "nanobot"
deploy = true
tags = ["app:ai", "server:unraid", "category:llm"]
[stack.config]
server = "unraid"
poll_for_updates = false
auto_update = true
destroy_before_deploy = true
auto_update_all_services = false
linked_repo = "docker-compose"
run_directory = "./stacks/llm/nanobot/"
file_paths = ["compose.yaml"]
environment = """
TZ=Asia/Ho_Chi_Minh
LITELLM_MASTER_KEY=[[LITELLM_MASTER_KEY]]
"""
pre_deploy.command = """
mkdir -p /mnt/user/appdata/nanobot/config
chown -R 1000:100 /mnt/user/appdata/nanobot/config
echo '{"providers":{"custom":{"apiBase":"http://100.68.251.84:4001/v1"}},"agents":{"defaults":{"provider":"custom","model":"glm-5-turbo"}},"channels":{"telegram":{"enabled":true,"token":"...","groupPolicy":"mention","streaming":true},"websocket":{"enabled":true,"host":"0.0.0.0","port":8765,"tokenIssueSecret":"[[NANOBOT_WS_SECRET]]"}},"gateway":{"host":"0.0.0.0","port":18790}}' > /mnt/user/appdata/nanobot/config/config.json
chown 1000:100 /mnt/user/appdata/nanobot/config/config.json
"""
Checklist (New Custom Image Stack)¶
-
stacks/{category}/{service}/Dockerfile— build definition -
stacks/{category}/{service}/compose.yaml— referencesgit.minhluc.info/lucndm/{service}:latest -
komodo/builds/{service}.toml— Build resource withbuilder_id = "local" -
komodo/stacks/{category}/{service}.toml— Stack resource,file_paths = ["compose.yaml"]only - Add
RunBuildentry tosync-and-deployprocedure inkomodo/stacks/root_syncs/procedure.toml -
git push→run-sync builds→run-sync {category}→run-build {service}→deploy-stack {service}
Pattern B: External Fork Repo (Manual Build + Git Hash Tag)¶
For large upstream forks (e.g., LiteLLM) that have their own repo outside docker-compose.
When to Use¶
- Customizing a large upstream project with its own repo (fork or standalone)
- No Komodo Build resource needed — manual
docker build+docker push - Image tagged with git commit hash for traceability
Architecture¶
External repo: ~/workspaces/github.com/minhluc-info/{project}/
├── Dockerfile # Custom build with extra tools
docker-compose repo:
├── stacks/{category}/{service}/compose.yaml # References hash-tagged image
├── stacks/{category}/{service}/config/ # Mounted :ro config files
└── komodo/stacks/{category}/{service}.toml # Stack resource only
Workflow¶
1. Edit Dockerfile in external repo
2. Commit to external repo
3. Build locally: docker build -t {registry}/{name}:{git_hash} .
4. Push to registry: docker push {registry}/{name}:{git_hash}
5. Update compose.yaml image tag to new hash
6. Commit + push docker-compose repo
7. Deploy: km x -y deploy-stack {service}
Full Example: LiteLLM¶
External repo: ~/workspaces/github.com/minhluc-info/litellm/
Files¶
~/workspaces/github.com/minhluc-info/litellm/
├── Dockerfile # Upstream + custom tools (bd CLI, MCP pre-cache)
docker-compose repo:
├── stacks/llm/litellm/
│ ├── compose.yaml # image: 100.68.251.84:3005/lucndm/litellm:{hash}
│ └── config/
│ ├── parent-config.yaml # Includes all sub-configs
│ ├── model_config.yaml
│ ├── mcp_servers.yaml
│ ├── router_settings.yaml
│ ├── litellm_settings.yaml
│ ├── general_settings.yaml
│ ├── search_tools.yaml
│ └── guardrails.yaml
├── komodo/stacks/llm/litellm.toml
Build & Deploy Sequence¶
# 1. Edit Dockerfile in litellm repo
cd ~/workspaces/github.com/minhluc-info/litellm
vim Dockerfile
# 2. Commit
git add -A && git commit -m "feat(docker): description of change"
# 3. Build locally (test first)
docker build --target runtime -t litellm-test:latest .
# Verify: docker run --rm --entrypoint /usr/local/bin/bd litellm-test:latest --version
# 4. Tag with git hash + push to registry
NEW_TAG=$(git rev-parse --short HEAD)
docker tag litellm-test:latest "100.68.251.84:3005/lucndm/litellm:${NEW_TAG}"
docker push "100.68.251.84:3005/lucndm/litellm:${NEW_TAG}"
# 5. Update compose.yaml in docker-compose repo
cd ~/workspaces/github.com/minhluc-info/docker-compose
# Edit image tag in stacks/llm/litellm/compose.yaml
# 6. Commit + push + deploy (compose-only change, no sync needed)
git add stacks/llm/litellm/compose.yaml
git commit -m "feat(litellm): bump image to {hash} — description"
git push
km x -y deploy-stack litellm
# If config files also changed (mcp_servers.yaml etc):
# Config is mounted :ro from host, pre_deploy copies it. Re-deploy picks up changes.
km x -y deploy-stack litellm
Volume Mounts¶
volumes:
# Config: pre_deploy copies from repo, mounted read-only
- /mnt/user/appdata/litellm/config/:/app/config:ro
# Beads issue tracker DB (persistent across redeploy)
- /mnt/user/appdata/litellm/beads:/app/.beads
# Dolt-beads data (shared with dolt-beads service)
- /mnt/user/appdata/dolt-beads/data:/mnt/user/appdata/dolt-beads/data
MCP Server Pre-Cache¶
The Dockerfile pre-installs MCP tools via uvx so LiteLLM doesn't download on each call:
RUN uvx --cache-dir /app/.cache/uvx crawl4ai-mcp-server --help 2>/dev/null || true
RUN uvx --cache-dir /app/.cache/uvx lightrag-mcp --help 2>/dev/null || true
RUN uvx --cache-dir /app/.cache/uvx beads-mcp --help 2>/dev/null || true
External binaries downloaded in separate build stage (no curl in runtime image):
FROM $LITELLM_BUILD_IMAGE AS bd-downloader
RUN apk add --no-cache curl && \
BD_VER=1.0.4 && \
curl -fsSL "https://github.com/gastownhall/beads/releases/download/v${BD_VER}/beads_${BD_VER}_linux_amd64.tar.gz" | \
tar xz -C /tmp && \
mv /tmp/bd /tmp/bd-bin
# In runtime stage:
COPY --from=bd-downloader /tmp/bd-bin /usr/local/bin/bd
Pitfalls¶
- Wolfi base image has no curl/wget in runtime stage. Use multi-stage build to download binaries.
- MCP config must be top-level in YAML (not nested under
litellm_settings). Seemcp_servers.yaml. BEADS_DIRnotBEADS_WORKING_DIR. Thebeads-mcpserver needsBEADS_DIRpointing to/app/.beadsdirectory, not a working directory.- Hash tag must be unique per build. Same hash = Docker uses cached image on deploy.
- Config changes need redeploy (volume is
:ro, pre_deploy copies fresh config each time).