Skip to content

Commit 1611cdd

Browse files
authored
fix: optimize Dockerfiles with multi-stage builds (#546)
* fix: optimize Dockerfiles with multi-stage builds and uv best practices - Switch all 3 Dockerfiles to multi-stage builds (uv builder → python:3.13-slim-bookworm runtime) - Addon images reduced from 1.66 GB to 329 MB by removing uv and full bookworm from runtime - Use uv sync --locked with uv.lock for reproducible builds - Add --mount=type=cache for uv download cache, --no-install-project for dependency layer caching - Enable UV_COMPILE_BYTECODE=1 for faster startup, UV_LINK_MODE=copy for cache mount compatibility - Pin uv version to 0.9.29 across all Dockerfiles - Add ARG declarations for BUILD_VERSION/BUILD_ARCH in addon Dockerfiles - Use --no-editable for addon images (no source in runtime), editable for root (fastmcp needs source) - Replace ENTRYPOINT+CMD with just CMD in root Dockerfile - Add # syntax=docker/dockerfile:1 to all Dockerfiles for explicit BuildKit - Fix addon tests: use subprocess for docker build (SDK doesn't support BuildKit) - Fix addon tests: mount /data as rw so start.py can persist secret_path.txt - Fix addon tests: wait for Uvicorn startup instead of premature log match - Update docker build test to verify uv is excluded from runtime image * fix: update Dockerfile HTTP mode comment to match docs
1 parent f2f472a commit 1611cdd

7 files changed

Lines changed: 130 additions & 135 deletions

File tree

.github/dependabot.yml

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,7 @@
11
version: 2
22
updates:
3-
# Update standalone Docker image base
4-
- package-ecosystem: "docker"
5-
directory: "/"
6-
schedule:
7-
interval: "weekly"
8-
day: "monday"
9-
time: "09:00"
10-
open-pull-requests-limit: 5
11-
labels:
12-
- "dependencies"
13-
- "docker"
14-
commit-message:
15-
prefix: "build"
16-
include: "scope"
17-
18-
# Update add-on Docker image base
19-
- package-ecosystem: "docker"
20-
directory: "/homeassistant-addon"
21-
schedule:
22-
interval: "weekly"
23-
day: "monday"
24-
time: "09:00"
25-
labels:
26-
- "dependencies"
27-
- "docker"
28-
- "addon"
29-
commit-message:
30-
prefix: "build"
31-
include: "scope"
3+
# Docker updates are now handled by Renovate (see renovate.json)
4+
# Dependabot's Docker ecosystem removed to avoid duplicate PRs
325

336
# Update GitHub Actions
347
- package-ecosystem: "github-actions"

Dockerfile

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,56 @@
1+
# syntax=docker/dockerfile:1
12
# Home Assistant MCP Server - Production Docker Image
2-
# Uses uv for fast, reliable Python package management
3+
# Multi-stage build: uv for dependency resolution, slim Python for runtime
34
# Python 3.13 - Security support until 2029-10
4-
# uv version pinned - Dependabot will create PRs for updates
5+
# Base images pinned by digest - Renovate will create PRs for updates
56

6-
FROM ghcr.io/astral-sh/uv:0.9.28-python3.13-bookworm-slim
7+
# --- Build stage: install dependencies with uv ---
8+
FROM ghcr.io/astral-sh/uv:0.9.29-python3.13-bookworm-slim@sha256:3133feeeaa71f24379a7c0d37c0a01195d75cfe9369b2c6df1505817f7ff51cd AS builder
9+
10+
WORKDIR /app
11+
12+
# Compile bytecode for faster startup; copy mode required with cache mounts
13+
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
14+
15+
# Install dependencies first (cached separately from source changes)
16+
COPY pyproject.toml uv.lock ./
17+
RUN --mount=type=cache,target=/root/.cache/uv \
18+
uv sync --locked --no-install-project --no-dev
19+
20+
# Copy source and config, then install the project itself
21+
COPY src/ ./src/
22+
RUN --mount=type=cache,target=/root/.cache/uv \
23+
uv sync --locked --no-dev
24+
25+
# --- Runtime stage: clean image without uv ---
26+
FROM python:3.13-slim-bookworm@sha256:8092ae2ef67061f9db412458dbdce44dbf16748fb3cae5cdbd020f467a9712d0
727

828
LABEL org.opencontainers.image.title="Home Assistant MCP Server" \
929
org.opencontainers.image.description="AI assistant integration for Home Assistant via Model Context Protocol" \
1030
org.opencontainers.image.source="https://github.com/homeassistant-ai/ha-mcp" \
1131
org.opencontainers.image.licenses="MIT" \
1232
io.modelcontextprotocol.server.name="io.github.homeassistant-ai/ha-mcp"
1333

14-
WORKDIR /app
34+
# Create non-root user for security
35+
RUN groupadd -r mcpuser && useradd -r -g mcpuser -m mcpuser
1536

16-
# Copy project files
17-
COPY pyproject.toml ./
18-
COPY src/ ./src/
19-
COPY fastmcp.json fastmcp-http.json ./
37+
WORKDIR /app
2038

21-
# Install dependencies and project with uv
22-
# --no-cache: Don't cache downloaded packages
23-
# --system: Install into system Python (not a virtual environment)
24-
RUN uv pip install --system --no-cache .
39+
# Copy the virtual environment, source, and config from builder
40+
COPY --chown=mcpuser:mcpuser --from=builder /app/.venv /app/.venv
41+
COPY --chown=mcpuser:mcpuser --from=builder /app/src /app/src
42+
COPY --chown=mcpuser:mcpuser fastmcp.json fastmcp-http.json ./
2543

26-
# Create non-root user for security
27-
RUN groupadd -r mcpuser && useradd -r -g mcpuser -m mcpuser && \
28-
chown -R mcpuser:mcpuser /app
2944
USER mcpuser
3045

46+
# Activate virtual environment via PATH
47+
ENV PATH="/app/.venv/bin:$PATH"
48+
3149
# Environment variables (can be overridden)
3250
ENV HOMEASSISTANT_URL="" \
3351
HOMEASSISTANT_TOKEN="" \
3452
BACKUP_HINT="normal"
3553

3654
# Default: Run in stdio mode using fastmcp.json
37-
# For HTTP mode, override with: docker run ... ha-mcp fastmcp run fastmcp-http.json
38-
ENTRYPOINT ["uv", "run", "--no-project"]
55+
# For HTTP mode: docker run ... IMAGE ha-mcp-web
3956
CMD ["fastmcp", "run", "fastmcp.json"]

homeassistant-addon-dev/Dockerfile

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,41 @@
1+
# syntax=docker/dockerfile:1
12
# Home Assistant MCP Server Add-on (Dev Channel)
2-
# Pulls from :dev Docker tag for latest development builds
3+
# Multi-stage build: uv for dependency resolution, slim Python for runtime
34
# WARNING: This is unstable and may break at any time
45

5-
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
6+
# --- Build stage: install dependencies with uv ---
7+
FROM ghcr.io/astral-sh/uv:0.9.29-python3.13-bookworm-slim@sha256:3133feeeaa71f24379a7c0d37c0a01195d75cfe9369b2c6df1505817f7ff51cd AS builder
68

79
WORKDIR /app
810

9-
# Copy project files from project root
10-
COPY pyproject.toml ./
11-
COPY src ./src
11+
# Compile bytecode for faster startup; copy mode required with cache mounts
12+
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
1213

13-
# Install dependencies and project with uv
14-
RUN uv pip install --system --no-cache .
14+
# Install dependencies first (cached separately from source changes)
15+
COPY pyproject.toml uv.lock ./
16+
RUN --mount=type=cache,target=/root/.cache/uv \
17+
uv sync --locked --no-install-project --no-dev
1518

16-
# Copy Python startup script (shared with stable)
19+
# Copy source and install the project itself
20+
COPY src/ ./src/
21+
RUN --mount=type=cache,target=/root/.cache/uv \
22+
uv sync --locked --no-dev --no-editable
23+
24+
# --- Runtime stage: clean image without uv ---
25+
FROM python:3.13-slim-bookworm@sha256:8092ae2ef67061f9db412458dbdce44dbf16748fb3cae5cdbd020f467a9712d0
26+
27+
WORKDIR /app
28+
29+
# Copy the virtual environment and startup script from builder
30+
COPY --from=builder /app/.venv /app/.venv
1731
COPY homeassistant-addon/start.py /
18-
RUN chmod a+x /start.py
32+
33+
# Activate virtual environment via PATH
34+
ENV PATH="/app/.venv/bin:$PATH"
1935

2036
# Labels
37+
ARG BUILD_VERSION
38+
ARG BUILD_ARCH
2139
LABEL \
2240
io.hass.name="Home Assistant MCP Server (Dev)" \
2341
io.hass.description="Development channel - AI assistant integration via MCP" \

homeassistant-addon/Dockerfile

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
1+
# syntax=docker/dockerfile:1
12
# Home Assistant MCP Server Add-on
2-
# Uses same uv base as standalone for consistency
3+
# Multi-stage build: uv for dependency resolution, slim Python for runtime
34
# Python 3.13 - Security support until 2029-10
45
# No bashio needed - pure Python startup script
5-
# Multi-platform uv image supports all HA architectures automatically
66

7-
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
7+
# --- Build stage: install dependencies with uv ---
8+
FROM ghcr.io/astral-sh/uv:0.9.29-python3.13-bookworm-slim@sha256:3133feeeaa71f24379a7c0d37c0a01195d75cfe9369b2c6df1505817f7ff51cd AS builder
89

910
WORKDIR /app
1011

11-
# Copy project files from project root
12-
COPY pyproject.toml ./
13-
COPY src ./src
12+
# Compile bytecode for faster startup; copy mode required with cache mounts
13+
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
1414

15-
# Install dependencies and project with uv
16-
RUN uv pip install --system --no-cache .
15+
# Install dependencies first (cached separately from source changes)
16+
COPY pyproject.toml uv.lock ./
17+
RUN --mount=type=cache,target=/root/.cache/uv \
18+
uv sync --locked --no-install-project --no-dev
1719

18-
# Copy Python startup script
20+
# Copy source and install the project itself
21+
COPY src/ ./src/
22+
RUN --mount=type=cache,target=/root/.cache/uv \
23+
uv sync --locked --no-dev --no-editable
24+
25+
# --- Runtime stage: clean image without uv ---
26+
FROM python:3.13-slim-bookworm@sha256:8092ae2ef67061f9db412458dbdce44dbf16748fb3cae5cdbd020f467a9712d0
27+
28+
WORKDIR /app
29+
30+
# Copy the virtual environment and startup script from builder
31+
COPY --from=builder /app/.venv /app/.venv
1932
COPY homeassistant-addon/start.py /
20-
RUN chmod a+x /start.py
33+
34+
# Activate virtual environment via PATH
35+
ENV PATH="/app/.venv/bin:$PATH"
2136

2237
# Labels
38+
ARG BUILD_VERSION
39+
ARG BUILD_ARCH
2340
LABEL \
2441
io.hass.name="Home Assistant MCP Server" \
2542
io.hass.description="AI assistant integration via Model Context Protocol" \

renovate.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"after 3pm on tuesday"
99
],
1010
"enabledManagers": [
11-
"custom.regex"
11+
"custom.regex",
12+
"dockerfile"
1213
],
1314
"ignorePaths": [],
1415
"customManagers": [

tests/addon/test_addon_startup.py

Lines changed: 34 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
"""Test Home Assistant add-on startup and logging."""
22

33
import json
4+
import subprocess
45
import time
5-
from pathlib import Path
66

77
import pytest
88
from testcontainers.core.container import DockerContainer
99
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
1010

11+
IMAGE_TAG = "ha-mcp-addon-test"
12+
DOCKERFILE = "homeassistant-addon/Dockerfile"
13+
14+
15+
def _build_addon_image():
16+
"""Build the addon test image via docker CLI (supports BuildKit)."""
17+
result = subprocess.run(
18+
[
19+
"docker", "build",
20+
"-t", IMAGE_TAG,
21+
"-f", DOCKERFILE,
22+
"--build-arg", "BUILD_VERSION=1.0.0-test",
23+
"--build-arg", "BUILD_ARCH=amd64",
24+
".",
25+
],
26+
capture_output=True,
27+
text=True,
28+
)
29+
if result.returncode != 0:
30+
pytest.fail(f"Failed to build {IMAGE_TAG}:\n{result.stderr}")
31+
1132

1233
@pytest.mark.slow
1334
class TestAddonStartup:
1435
"""Test add-on container startup behavior."""
1536

37+
@pytest.fixture(autouse=True, scope="class")
38+
def build_image(self):
39+
"""Build the addon image once before all tests in this class."""
40+
_build_addon_image()
41+
1642
@pytest.fixture
1743
def addon_config(self, tmp_path):
1844
"""Create a test add-on configuration file."""
@@ -27,38 +53,20 @@ def addon_config(self, tmp_path):
2753

2854
@pytest.fixture
2955
def container(self, addon_config):
30-
"""Build and start the add-on container for testing."""
31-
# Build the Docker image from the Dockerfile
32-
dockerfile_path = Path("homeassistant-addon/Dockerfile")
33-
context_path = Path(".")
34-
35-
container = (
36-
DockerContainer(image="ha-mcp-addon-test")
56+
"""Create the add-on container for testing (image built by build_image fixture)."""
57+
return (
58+
DockerContainer(image=IMAGE_TAG)
3759
.with_bind_ports(9583, 9583)
3860
.with_env("SUPERVISOR_TOKEN", "test-supervisor-token")
3961
.with_env("HOMEASSISTANT_URL", "http://supervisor/core")
40-
.with_volume_mapping(str(addon_config.parent), "/data", mode="ro")
41-
)
42-
43-
# Build the image first (use as_posix() for Windows compatibility)
44-
container.get_docker_client().client.images.build(
45-
path=str(context_path),
46-
dockerfile=dockerfile_path.as_posix(),
47-
tag="ha-mcp-addon-test",
48-
rm=True,
49-
buildargs={
50-
"BUILD_VERSION": "1.0.0-test",
51-
"BUILD_ARCH": "amd64",
52-
},
62+
.with_volume_mapping(str(addon_config.parent), "/data", mode="rw")
5363
)
5464

55-
return container
56-
5765
def test_addon_startup_logs(self, container):
5866
"""Test that add-on produces expected startup logs."""
5967
# Configure wait strategy for server actually starting
6068
container.waiting_for(
61-
LogMessageWaitStrategy("Starting MCP server").with_startup_timeout(30)
69+
LogMessageWaitStrategy("Uvicorn running on").with_startup_timeout(30)
6270
)
6371

6472
# Start container
@@ -103,33 +111,14 @@ def test_addon_startup_custom_secret_path(self, tmp_path):
103111
with open(config_file, "w") as f:
104112
json.dump(config, f)
105113

106-
# Build and start container
107-
dockerfile_path = Path("homeassistant-addon/Dockerfile")
108-
context_path = Path(".")
109-
110114
container = (
111-
DockerContainer(image="ha-mcp-addon-test")
115+
DockerContainer(image=IMAGE_TAG)
112116
.with_bind_ports(9583, 9583)
113117
.with_env("SUPERVISOR_TOKEN", "test-supervisor-token")
114118
.with_env("HOMEASSISTANT_URL", "http://supervisor/core")
115119
.with_volume_mapping(str(config_file.parent), "/data", mode="rw")
116120
)
117121

118-
# Build if not already built (use as_posix() for Windows compatibility)
119-
try:
120-
container.get_docker_client().client.images.get("ha-mcp-addon-test")
121-
except Exception:
122-
container.get_docker_client().client.images.build(
123-
path=str(context_path),
124-
dockerfile=dockerfile_path.as_posix(),
125-
tag="ha-mcp-addon-test",
126-
rm=True,
127-
buildargs={
128-
"BUILD_VERSION": "1.0.0-test",
129-
"BUILD_ARCH": "amd64",
130-
},
131-
)
132-
133122
# Configure wait strategy
134123
container.waiting_for(
135124
LogMessageWaitStrategy("MCP Server URL:").with_startup_timeout(30)
@@ -152,31 +141,12 @@ def test_addon_startup_custom_secret_path(self, tmp_path):
152141

153142
def test_addon_startup_missing_supervisor_token(self, addon_config):
154143
"""Test that add-on exits with error when SUPERVISOR_TOKEN is missing."""
155-
# Build and start container without SUPERVISOR_TOKEN
156-
dockerfile_path = Path("homeassistant-addon/Dockerfile")
157-
context_path = Path(".")
158-
159144
container = (
160-
DockerContainer(image="ha-mcp-addon-test")
145+
DockerContainer(image=IMAGE_TAG)
161146
.with_bind_ports(9583, 9583)
162147
.with_volume_mapping(str(addon_config.parent), "/data", mode="ro")
163148
)
164149

165-
# Build if not already built (use as_posix() for Windows compatibility)
166-
try:
167-
container.get_docker_client().client.images.get("ha-mcp-addon-test")
168-
except Exception:
169-
container.get_docker_client().client.images.build(
170-
path=str(context_path),
171-
dockerfile=dockerfile_path.as_posix(),
172-
tag="ha-mcp-addon-test",
173-
rm=True,
174-
buildargs={
175-
"BUILD_VERSION": "1.0.0-test",
176-
"BUILD_ARCH": "amd64",
177-
},
178-
)
179-
180150
container.start()
181151

182152
try:

0 commit comments

Comments
 (0)