Containerize Your Application¶
How to package a cosalette application as a Docker image, including hardware-specific customisation and multi-architecture builds for Raspberry Pi targets.
Prerequisites
- Docker Engine ≥ 20.10 (with BuildKit)
- uv for Python package management
Dockerfile¶
A multi-stage Dockerfile that works for most cosalette applications. It uses uv for
dependency resolution and produces a minimal runtime image.
# syntax=docker/dockerfile:1
# ──────────────────────────────────────────────
# Stage 1 — builder
# Resolve dependencies and install the app into
# a virtual environment. Nothing from this stage
# ships in the final image except the venv.
# ──────────────────────────────────────────────
FROM python:3.14-slim AS builder
# Grab the uv binary from the official image.
# Use a stable minor-series tag; replace with a fully pinned
# version tag or image digest for strictly reproducible builds.
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
WORKDIR /app
# Compile bytecode at build time. The non-root runtime
# user cannot write .pyc files, so without this every
# start compiles all imports again (see Memory Footprint).
ENV UV_COMPILE_BYTECODE=1
# Copy dependency metadata first — this layer is
# cached until pyproject.toml or uv.lock change.
COPY pyproject.toml uv.lock ./
# Install production dependencies only (no dev
# extras). --frozen ensures the lock file is used
# as-is without re-resolving.
RUN uv sync --frozen --no-dev --no-install-project
# Now copy the rest of the source tree and install
# the project itself. Make sure to add a .dockerignore
# excluding .git/, tests/, docs/, and *.md to keep
# the build context small.
COPY . .
RUN uv sync --frozen --no-dev
# ──────────────────────────────────────────────
# Stage 2 — runtime
# Minimal image with only what the app needs to
# run. No compilers, no build tools, no uv.
# ──────────────────────────────────────────────
FROM python:3.14-slim AS runtime
# Create a non-root user for the application.
RUN groupadd --gid 1000 app \
&& useradd --uid 1000 --gid app --create-home app
WORKDIR /app
# Copy the virtual environment from the builder.
COPY --from=builder /app/.venv /app/.venv
# Put the venv's bin directory on PATH so the
# console script entry point is directly callable.
ENV PATH="/app/.venv/bin:$PATH"
# Tell Python not to buffer stdout/stderr — logs
# appear immediately in `docker logs`.
ENV PYTHONUNBUFFERED=1
# Use SIGTERM for graceful shutdown. cosalette's
# signal handler catches this and shuts down cleanly.
STOPSIGNAL SIGTERM
USER app
# Replace "myapp" with your console script name
# (the [project.scripts] entry in pyproject.toml).
ENTRYPOINT ["myapp"]
Console script vs. module
The ENTRYPOINT above assumes a console script defined in pyproject.toml
under [project.scripts]. If your app uses __main__.py instead, change the
entrypoint to:
Customising for Hardware¶
IoT applications often need system-level libraries for hardware access. Add the required packages in the runtime stage before switching to the non-root user:
No extra system packages needed — pyserial works out of the box. Just make
sure the container has access to the serial device (see
Docker Compose — devices in the Deploy guide).
Multi-Architecture Builds¶
Both the Raspberry Pi 4 and Raspberry Pi Zero 2 W use arm64 (aarch64), so a single image target covers both boards.
Cross-building from an amd64 dev machine¶
Use Docker BuildKit with buildx to cross-compile:
# One-time setup: create a builder with QEMU support
docker buildx create --name pibuilder --use
docker buildx inspect --bootstrap
# Build and push a multi-arch image
docker buildx build \
--platform linux/arm64 \
--tag registry.example.com/myapp:latest \
--push \
.
QEMU emulation
docker buildx uses QEMU under the hood for cross-platform builds. On most
Docker Desktop and modern Linux installations, QEMU user-mode emulation is
already configured. If not, enable it with:
Pi Zero 2 W memory constraints
The Pi Zero 2 W has only 512 MB RAM. Keep your images lean:
- Use
python:3.14-slim(not the full image). - Avoid heavy dependencies where possible.
- Set
MYAPP_LOGGING__LEVEL=WARNINGin production to reduce log volume. - Prefer
MemoryStoreorNullStoreoverSqliteStoreif persistence isn't critical — SQLite's page cache can be memory-hungry on constrained devices.
Building natively on the Pi¶
If you're building directly on a Pi 4 (which has 4–8 GB RAM), a standard
docker build works without any special flags:
Avoid building on the Pi Zero 2 W — its limited RAM makes builds unreliable. Cross-build on a dev machine or CI instead.
Memory Footprint¶
An adopter's app with Home Assistant discovery, one BLE sensor and an MQTT client uses about 52 MiB of resident memory on CPython 3.14. Most of it is loaded code: Python keeps every imported module in memory, so the footprint drops only when the app loads fewer modules. The file-backed part (about 18 MiB) is shared between containers that run the same image.
cosalette keeps its own share small: app.cli() starts a plain run without loading
the Typer CLI, and cron schedules, publish strategies, the command and stream
runners and the discovery generator load only when the app uses them.
Two settings make a measurable difference:
- Compile bytecode in the image. The Dockerfile above sets
UV_COMPILE_BYTECODE=1. Without it, a non-root user cannot write.pycfiles and Python compiles every import at each start. One adopter measured 4 MiB more resident memory, a 7 MiB higher peak and about 70 % more start-up CPU without it. - Keep the default allocator. Do not set
PYTHONMALLOC=malloc. On a musl (Alpine) image it increased resident memory by about 3 MiB.MALLOC_ARENA_MAXis a glibc allocator control and is ignored by musl. On glibc, even a process with two or three threads may use multiple arenas, so its memory and contention effects depend on the workload.
Slim Images¶
Image size matters on small hosts and slow links, even where it does not change resident memory. Three things keep a cosalette image small: leave out rich, install only the extras the app uses, and know which parts of the wheel are there on purpose.
Leave Out rich¶
Typer, which cosalette uses for its CLI, requires rich, which pulls in pygments,
markdown-it-py and mdurl (about 18 MB). They load only for --help and CLI
errors, so they add image size, not resident memory. cosalette cannot drop them from
its own dependencies, but an image can skip them at install time. Add the four
--no-install-package flags to both uv sync lines of the builder stage and set
TYPER_USE_RICH=0 in the runtime stage:
# Builder stage
RUN uv sync --frozen --no-dev --no-install-project \
--no-install-package rich --no-install-package pygments \
--no-install-package markdown-it-py --no-install-package mdurl
COPY . .
RUN uv sync --frozen --no-dev \
--no-install-package rich --no-install-package pygments \
--no-install-package markdown-it-py --no-install-package mdurl
# Runtime stage
ENV TYPER_USE_RICH=0
The lockfile is used as-is and nothing is re-resolved, so your dev environment keeps
rich. The recipe works with the ghcr.io/astral-sh/uv:0.6 image pinned above.
If the image installs from an exported requirements file instead of uv sync, prune
rich from the export and install with --no-deps:
RUN uv venv \
&& uv export --frozen --no-dev --no-emit-project --prune rich \
-o requirements.txt \
&& uv pip install --no-deps -r requirements.txt
COPY . .
RUN uv pip install --no-deps .
--prune rich drops rich and the three packages only it needs. --no-deps is
required: without it, uv resolves the dependencies of the listed packages again,
finds that typer requires rich and installs all four. The example installs the
project itself with --no-deps after copying its source.
- Plain output. Help and usage errors of the CLIs cosalette builds (the app CLI,
its
schemagroup and thecosalettecommand) fall back to plain output when rich is missing, with the same exit codes. TYPER_USE_RICH=0is needed only when the app creates its owntyper.Typer(). Typer decides whether to use rich from this variable alone, so without it such a CLI crashes on--helpwithModuleNotFoundError: No module named 'rich'. The variable is harmless for every other app.pip checkreports rich as missing.uv pip checkandpip checkin the image report that typer requires rich. This is expected.
Do not use override-dependencies for this
Older cosalette docs recommended
[tool.uv] override-dependencies = ["rich; sys_platform == 'never'"]. Overrides
apply to the whole lock, including dev dependency groups, so the entry also
removes rich from dev tools that need it, such as pip-audit, cyclonedx and
fastmcp. Remove the entry and use the flags above (ADR-005 amendment).
Install Only the Extras You Use¶
Most apps need no cosalette extra at runtime. Runtime discovery (app.discovery())
and a schema file in JSON format need none. cosalette[schema] is needed only for a
YAML schema file, for publish-time payload validation and for schema CLI commands
that read or write YAML, and cosalette[mcp] only for the MCP server, which runs in
your editor, not in the image. Check the
Which Extra Do I Need? table before
you add an extra to the app's runtime dependencies.
A slim image without PyYAML can still generate a schema and the artefacts derived
from it, because schema dump --format json writes JSON and the other schema
commands read a .json schema:
cosalette schema dump --app myapp.app:app --format json > schema.json
cosalette schema openhab schema.json
If CI runs schema commands that need YAML, put the extra in a dev dependency group:
uv sync --no-dev then keeps it out of the image.
What the Runtime Wheel Contains¶
The cosalette wheel intentionally ships some content that a running daemon does not
use: the AI guidance and help text, the ADR index (gzip-compressed), the cosalette
CLI with its health and schema commands, and the pytest plugin (ADR-034). They
cost about 1 MB, and the cosalette health probe must be available on every
install. Leave them in place; the savings above are larger.
Related guides:
- Deploy with Docker Compose — Compose configuration, health checks, persistence, and Ansible rollout
- Harden Your Deployment — security hardening, image scanning, and production logging
- Troubleshoot a Deployment — diagnosing common problems