Deployment¶
How to containerise and deploy cosalette applications using Docker and Docker Compose.
Prerequisites
- Docker Engine ≥ 20.10 (with BuildKit)
- Docker Compose V2
- 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
# 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 below).
Docker Compose¶
A reference docker-compose.yml for a typical cosalette app running alongside an
MQTT broker.
services:
# ── MQTT broker ──────────────────────────────
mosquitto:
image: eclipse-mosquitto:2
restart: unless-stopped
# Development-only: bind to localhost so the broker is not exposed to
# the LAN/Internet. For production, configure authentication and TLS
# in mosquitto.conf and expose only a TLS listener (e.g. 8883).
ports:
- "127.0.0.1:1883:1883"
volumes:
- mosquitto-config:/mosquitto/config
- mosquitto-data:/mosquitto/data
- mosquitto-log:/mosquitto/log
# ── cosalette application ───────────────────
myapp:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- mosquitto
environment:
# ── MQTT ──
MYAPP_MQTT__HOST: mosquitto # (1)!
MYAPP_MQTT__PORT: "1883"
MYAPP_MQTT__USERNAME: myapp
MYAPP_MQTT__PASSWORD: changeme
MYAPP_MQTT__CLIENT_ID: myapp-prod
MYAPP_MQTT__TOPIC_PREFIX: myapp
# ── Logging ──
MYAPP_LOGGING__LEVEL: INFO
MYAPP_LOGGING__FORMAT: json # (2)!
# ── App-specific ──
# MYAPP_SERIAL_PORT: /dev/ttyUSB0
# MYAPP_POLL_INTERVAL: "60"
volumes:
- app-data:/app/data # (3)!
# ── Hardware devices (uncomment as needed) ──
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0 # Serial
# - /dev/gpiochip0:/dev/gpiochip0 # GPIO
# - /dev/i2c-1:/dev/i2c-1 # I²C
volumes:
mosquitto-config:
mosquitto-data:
mosquitto-log:
app-data:
- Use the service name (
mosquitto) as the hostname — Docker's internal DNS resolves it automatically. Never uselocalhosthere; that refers to the container itself, not the broker. - JSON logging is recommended for containers — see Logging below.
- Mount a volume for persistence stores (
JsonFileStore,SqliteStore). See Persistence.
Environment Variable Reference¶
All variables use the app's env_prefix (here MYAPP_) followed by __ for nested
fields.
MQTT Settings¶
| Variable | Settings Field | Default | Description |
|---|---|---|---|
MYAPP_MQTT__HOST |
mqtt.host |
localhost |
MQTT broker hostname |
MYAPP_MQTT__PORT |
mqtt.port |
1883 |
MQTT broker port |
MYAPP_MQTT__USERNAME |
mqtt.username |
None |
Broker username |
MYAPP_MQTT__PASSWORD |
mqtt.password |
None |
Broker password |
MYAPP_MQTT__CLIENT_ID |
mqtt.client_id |
"" (auto-generated) |
MQTT client identifier |
MYAPP_MQTT__TOPIC_PREFIX |
mqtt.topic_prefix |
"" (falls back to app name) |
Base prefix for all topics |
MYAPP_MQTT__RECONNECT_INTERVAL |
mqtt.reconnect_interval |
5 |
Initial reconnect delay (seconds) |
MYAPP_MQTT__RECONNECT_MAX_INTERVAL |
mqtt.reconnect_max_interval |
300 |
Maximum reconnect delay (seconds) |
Logging Settings¶
| Variable | Settings Field | Default | Description |
|---|---|---|---|
MYAPP_LOGGING__LEVEL |
logging.level |
INFO |
Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
MYAPP_LOGGING__FORMAT |
logging.format |
json |
Output format (json or text) |
MYAPP_LOGGING__FILE |
logging.file |
None |
Log file path (usually unset in containers) |
MYAPP_LOGGING__MAX_FILE_SIZE_MB |
logging.max_file_size_mb |
10 |
Max log file size before rotation |
MYAPP_LOGGING__BACKUP_COUNT |
logging.backup_count |
3 |
Number of rotated log files to keep |
How env var nesting works
pydantic-settings maps environment variables to nested models using the
env_nested_delimiter. With env_nested_delimiter="__" and
env_prefix="MYAPP_":
MYAPP_MQTT__HOST=broker.local
^^^^ ^^^^
│ └─ field name on MqttSettings
└────── sub-model name on Settings
The framework's base Settings declares mqtt: MqttSettings and
logging: LoggingSettings, so the MQTT__ and LOGGING__ segments route to
those sub-models. Your own flat fields (like MYAPP_POLL_INTERVAL) have no
double-underscore and map directly to top-level settings.
Because Settings is configured with extra="ignore", any environment variable
that doesn't match a known field is silently skipped — no validation errors from
unrelated system env vars.
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.
Health Checks¶
cosalette uses MQTT-native health reporting
(ADR-012) rather than an HTTP
health endpoint. The framework publishes a structured JSON heartbeat to
{prefix}/status and configures an MQTT Last Will and Testament (LWT) so the broker
automatically publishes an "offline" message if the client disconnects unexpectedly.
Why no HTTP health endpoint?¶
cosalette applications are pure MQTT daemons — adding an HTTP server solely for health checks would increase the attack surface, add dependencies, and consume resources on constrained devices. ADR-012 explicitly rejected this approach.
MQTT-based health check¶
If mosquitto_sub is available in the container, you can use it to verify the app's
MQTT heartbeat:
services:
myapp:
# ...
healthcheck:
test: >-
mosquitto_sub
-h mosquitto
-t "myapp/status"
-C 1
-W 30
interval: 60s
timeout: 35s
retries: 3
start_period: 15s
This subscribes to the status topic, waits up to 30 seconds (-W) for a single
message (-C 1), and exits 0 if one is received. You'll need mosquitto-clients
installed in the runtime image:
RUN apt-get update \
&& apt-get install -y --no-install-recommends mosquitto-clients \
&& rm -rf /var/lib/apt/lists/*
Process-based fallback¶
If you'd rather not add mosquitto-clients to the image, a simple process check
works as a basic health signal:
services:
myapp:
# ...
healthcheck:
test: ["CMD", "pgrep", "-f", "myapp"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
LWT handles crash detection automatically
Even without a Docker HEALTHCHECK, the MQTT broker publishes the LWT "offline"
message to {prefix}/status when the client TCP connection drops. Downstream
consumers (like Home Assistant) detect the outage without any polling.
Logging¶
Use JSON format in containers¶
Set --log-format json (or the MYAPP_LOGGING__FORMAT=json env var) for structured
NDJSON output. This is the recommended format for containerised deployments:
{"timestamp":"2026-03-05T10:15:30.123Z","level":"INFO","message":"Connected to broker","host":"mosquitto","port":1883}
Docker's default json-file log driver wraps each line in its own JSON envelope, so
structured log lines are preserved as single entries.
Let Docker handle log rotation¶
In a container, do not set MYAPP_LOGGING__FILE — write to stdout/stderr and let
the Docker daemon manage rotation:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
This applies globally to all containers. You can also set logging: per-service in
docker-compose.yml:
services:
myapp:
# ...
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Log aggregation¶
For fleet-wide observability, forward container logs to a centralised system. Grafana Loki with Promtail is a lightweight option that works well on Raspberry Pi hardware. Configure the Loki Docker logging driver or run Promtail as a sidecar container that tails the Docker log files.
Viewing logs¶
# Last 20 log entries
docker logs --tail 20 myapp
# Follow live output
docker logs -f myapp
# Filter structured logs with jq
docker logs myapp 2>&1 | jq 'select(.level == "ERROR")'
Persistence¶
JsonFileStore and SqliteStore write to disk and need a mounted volume to survive
container restarts. MemoryStore and NullStore are ephemeral and need no volume.
Volume mount¶
services:
myapp:
# ...
volumes:
- app-data:/app/data
volumes:
app-data:
Configure your store path to write inside the mounted directory (e.g.,
/app/data/state.json or /app/data/store.sqlite).
Permissions¶
The Dockerfile creates a non-root user (app, UID 1000). If the named volume is
freshly created, Docker sets ownership automatically. For bind mounts, ensure the
host directory is writable by UID 1000:
Graceful Shutdown¶
cosalette installs signal handlers for SIGTERM and SIGINT. When Docker sends
SIGTERM (via docker stop or Compose shutdown), the framework:
- Cancels all running device tasks.
- Publishes
"offline"to per-device availability topics. - Flushes persistence stores.
- Publishes a final status update to
{prefix}/status. - Disconnects from the MQTT broker cleanly.
The STOPSIGNAL SIGTERM directive in the Dockerfile ensures Docker sends the right
signal. The default stop_grace_period of 10 seconds in Compose is usually
sufficient. Increase it if your app has slow cleanup (e.g., large store flushes):
LWT as a safety net
If the process is killed hard (OOM, docker kill, power loss), the MQTT broker
publishes the pre-configured LWT "offline" message. The graceful shutdown
path and the LWT path converge on the same outcome — downstream consumers always
see an "offline" status.
Ansible Deployment¶
Ansible is a natural fit for deploying Compose-based applications to a fleet of
Raspberry Pis. The general pattern: template the docker-compose.yml with Jinja2,
copy it to each host, and let Compose manage the containers.
Note
Ansible playbooks are infrastructure-level tooling — outside the scope of the cosalette framework itself. This section provides a starting point, not a complete Ansible role.
Jinja2 template¶
services:
mosquitto:
image: eclipse-mosquitto:2
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- mosquitto-data:/mosquitto/data
{{ app_name }}:
image: "{{ docker_registry }}/{{ app_name }}:{{ app_version }}"
restart: unless-stopped
depends_on:
- mosquitto
environment:
{{ env_prefix }}_MQTT__HOST: mosquitto
{{ env_prefix }}_MQTT__USERNAME: "{{ mqtt_username }}"
{{ env_prefix }}_MQTT__PASSWORD: "{{ mqtt_password }}"
{{ env_prefix }}_MQTT__TOPIC_PREFIX: "{{ topic_prefix }}"
{{ env_prefix }}_LOGGING__LEVEL: "{{ log_level | default('INFO') }}"
{{ env_prefix }}_LOGGING__FORMAT: json
{% if serial_device is defined %}
devices:
- {{ serial_device }}:{{ serial_device }}
{% endif %}
volumes:
- app-data:/app/data
volumes:
mosquitto-data:
app-data:
Playbook snippet¶
- name: Deploy cosalette app
hosts: pis
tasks:
- name: Create app directory
ansible.builtin.file:
path: "/opt/{{ app_name }}"
state: directory
mode: "0755"
- name: Template docker-compose.yml
ansible.builtin.template:
src: templates/docker-compose.yml.j2
dest: "/opt/{{ app_name }}/docker-compose.yml"
mode: "0644"
- name: Pull and start services
community.docker.docker_compose_v2:
project_src: "/opt/{{ app_name }}"
pull: always
state: present
Define per-host variables in your Ansible inventory to customise each deployment (broker credentials, serial devices, topic prefixes, etc.).
Troubleshooting¶
- Container starts but no MQTT connection
- The broker hostname must be the Compose service name (e.g.,
mosquitto), notlocalhost. Inside a container,localhostrefers to the container itself. Verify name resolution withdocker exec myapp getent hosts mosquitto. - Permission denied on
/dev/ttyUSB0 -
The container needs access to the host device. Options:
- Add
device_cgroup_rules: ['c 188:* rmw']under the service. - Use
privileged: true(less secure, but simple for development). - Add the container user to the
dialoutgroup.
- Add
- Out of memory on Pi Zero 2 W
-
The Pi Zero 2 W has only 512 MB RAM. To reduce memory usage:
- Set
MYAPP_LOGGING__LEVEL=WARNINGto reduce log buffer pressure. - Use
MemoryStoreorNullStoreinstead ofSqliteStore. - Run
docker system pruneto reclaim space from old images. - Consider adding a swap file on the host.
- Set
- Container restarts in a loop
-
Check the exit code with
docker inspect --format='{{.State.ExitCode}}' myapp:Exit Code Meaning Action 1Configuration error Check env vars — missing required field, invalid value 3Runtime error Check logs with docker logs myappfor the root cause137OOM killed / SIGKILL Increase memory limit or reduce footprint - Image fails to build for arm64
-
Ensure BuildKit and QEMU are set up: