Skip to content

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 Syncbuilds 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

services:
  {service}:
    image: git.minhluc.info/lucndm/{service}:latest
    # ... rest of config

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.

# WRONG
file_paths = ["compose.yaml", "Dockerfile"]

# CORRECT
file_paths = ["compose.yaml"]

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:

km list builds -n {service} -f json

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:

user: "99:100"   # Match unraid nobody user when writing to appdata

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 — references git.minhluc.info/lucndm/{service}:latest
  • komodo/builds/{service}.toml — Build resource with builder_id = "local"
  • komodo/stacks/{category}/{service}.toml — Stack resource, file_paths = ["compose.yaml"] only
  • Add RunBuild entry to sync-and-deploy procedure in komodo/stacks/root_syncs/procedure.toml
  • git pushrun-sync buildsrun-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). See mcp_servers.yaml.
  • BEADS_DIR not BEADS_WORKING_DIR. The beads-mcp server needs BEADS_DIR pointing to /app/.beads directory, 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).