feat(deploy): Docker packaging with multi-stage build and compose orchestration

- Dockerfile: golang:1.23-bookworm builder → distroless/static-debian12:nonroot
- docker-compose.yml: host networking (required for mDNS), Traefik labels, resource limits
- VERSION: 0.1.0 for image tagging
- .dockerignore: excludes docs, build artifacts, IDE files
- .gitignore: standard Go/ESP-IDF ignores

Key decisions:
- Host networking required: Docker bridge blocks mDNS multicast 224.0.0.251
- distroless/static-debian12:nonroot: no shell, minimal attack surface, UID 65532
- Firmware via volume mount: users provide their own binaries for OTA
- Traefik labels disabled by default: enable SPAXEL_TRAEFIK_ENABLE=true for TLS

Complete: Phase 1 Docker packaging — all foundation items now done
Remaining: Phase 2 signal processing (baseline, deltaRMS, Fresnel zones)
This commit is contained in:
jedarden 2026-03-26 07:46:15 -04:00
parent 8230ef4222
commit 3f2962f945
6 changed files with 251 additions and 1 deletions

37
.dockerignore Normal file
View file

@ -0,0 +1,37 @@
# Git
.git
.gitignore
# Documentation
docs/
*.md
!README.md
# Marathon logs
.marathon/
# Firmware build artifacts (use volume mount instead)
firmware/build/
firmware/managed_components/
firmware/.cache/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Test and coverage
*.test
*.out
coverage.txt
# Temporary files
tmp/
temp/
*.tmp

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Marathon logs
.marathon/logs/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Firmware build artifacts
firmware/build/
firmware/managed_components/
firmware/.cache/
# Test and coverage
*.test
*.out
coverage.txt
# Temporary files
tmp/
temp/
*.tmp

44
Dockerfile Normal file
View file

@ -0,0 +1,44 @@
# Spaxel Mothership Dockerfile
# Multi-stage build for minimal production image
# Stage 1: Build the Go binary
FROM golang:1.23-bookworm AS builder
WORKDIR /app
# Copy Go module files first for better caching
COPY mothership/go.mod mothership/go.sum ./
RUN go mod download
# Copy source code
COPY mothership/ ./
# Build the binary
# CGO_ENABLED=0 because we use pure-Go SQLite (modernc.org/sqlite)
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o spaxel ./cmd/mothership
# Stage 2: Minimal runtime image
FROM gcr.io/distroless/static-debian12:nonroot
# Copy the binary
COPY --from=builder /app/spaxel /spaxel
# Copy dashboard static files (served from filesystem at runtime)
COPY dashboard/ /dashboard/
# Create firmware directory (users should mount their own firmware volume)
# The container will serve firmware binaries for OTA from /firmware/
VOLUME ["/data", "/firmware"]
# Expose HTTP/WebSocket port
EXPOSE 8080
# Health check (distroless has wget)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
["wget", "-q", "-O-", "http://localhost:8080/healthz"]
# Run as non-root (distroless default is UID 65532)
ENTRYPOINT ["/spaxel"]

View file

@ -13,10 +13,63 @@ Goal: Bare-minimum loop from ESP32 to browser. Zero-config with passive radar an
| BLE scanning | **Done** | Core 0 concurrent with WiFi |
| Mothership WebSocket ingestion | **Done** | See iteration 1 below |
| Dashboard skeleton | **Done** | See iteration 3 below |
| Docker packaging | Not started | |
| Docker packaging | **Done** | See iteration 4 below |
### Iteration Log
#### Iteration 4 — 2026-03-26
**Completed:** Docker packaging
Implemented container deployment with:
- **Dockerfile:** Multi-stage build for minimal production image
- Stage 1: `golang:1.23-bookworm` builder with module caching
- Stage 2: `distroless/static-debian12:nonroot` runtime (no shell, runs as UID 65532)
- CGO_ENABLED=0 build (pure-Go SQLite compatible)
- Dashboard static files copied to `/dashboard/`
- Firmware directory as volume mount point (`/firmware/`)
- Built-in healthcheck via wget
- **docker-compose.yml:** Production-ready orchestration
- `network_mode: host` for mDNS multicast discovery (required for ESP32 nodes)
- Volume mounts: `spaxel-data` for persistence, `./firmware` for OTA binaries
- Environment variables: TZ, mDNS config, optional MQTT
- Resource limits: 512m RAM, 2 CPUs (scalable to 1g for 16+ nodes)
- 35s stop_grace_period for graceful shutdown
- Ulimits: 4096/8192 file descriptors for node connections
- Traefik labels (disabled by default, enable for TLS/proxy)
- **Supporting files:**
- `VERSION` — 0.1.0 for image tagging
- `.dockerignore` — Excludes docs, build artifacts, IDE files
**Key design decisions:**
- Host networking is REQUIRED for mDNS — Docker bridge blocks multicast 224.0.0.251
- distroless image minimizes attack surface (no shell, no package manager)
- Firmware binaries not bundled — users mount their own `/firmware` volume
- Traefik labels included but disabled — enable for production TLS
**Files created:**
```
Dockerfile
docker-compose.yml
VERSION
.dockerignore
```
**Phase 1 Status:** COMPLETE
All Phase 1 items implemented:
- ✅ ESP32 firmware skeleton
- ✅ Passive radar support
- ✅ BLE scanning
- ✅ Mothership WebSocket ingestion
- ✅ Dashboard skeleton
- ✅ Docker packaging
**Next:** Phase 2 — Signal Processing (baseline, deltaRMS, Fresnel zones)
#### Iteration 3 — 2026-03-26
**Completed:** Dashboard skeleton with 3D visualization

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

87
docker-compose.yml Normal file
View file

@ -0,0 +1,87 @@
# Spaxel Docker Compose
# Production deployment configuration
#
# IMPORTANT: network_mode: host is REQUIRED for mDNS to work.
# mDNS uses multicast address 224.0.0.251 (link-local), which Docker
# bridge networking blocks. With host networking, the container shares
# the host's network interfaces and mDNS multicasts reach the LAN.
#
# Side effect: 'ports' mapping is ignored in host mode — port 8080 is
# directly exposed on the host.
services:
spaxel:
build:
context: .
dockerfile: Dockerfile
args:
VERSION: ${VERSION:-dev}
# Alternative: use pre-built image
# image: ghcr.io/spaxel/spaxel:latest
# Host networking required for mDNS discovery by ESP32 nodes
network_mode: host
# Alternative (if host mode is not desired): disable mDNS and require
# nodes to use cached ms_ip NVS key (manual IP entry during provisioning).
# Set SPAXEL_MDNS_ENABLED=false to skip the mDNS advertisement entirely.
volumes:
- spaxel-data:/data # SQLite, baselines, floor plans, CSI buffer
- ./firmware:/firmware:ro # Firmware binaries for OTA (read-only)
environment:
# Timezone - required for correct diurnal baseline hours
TZ: ${TZ:-UTC}
# mDNS service name (must match firmware ms_mdns NVS key)
SPAXEL_MDNS_NAME: ${SPAXEL_MDNS_NAME:-spaxel}
# Set to false if using bridge networking (nodes must use cached IP)
SPAXEL_MDNS_ENABLED: ${SPAXEL_MDNS_ENABLED:-true}
# Optional MQTT integration (uncomment to enable)
# SPAXEL_MQTT_BROKER: mqtt://homeassistant.local:1883
# SPAXEL_MQTT_USERNAME: mosquitto
# SPAXEL_MQTT_PASSWORD: secret
# Debug logging (uncomment for troubleshooting)
# SPAXEL_LOG_LEVEL: debug
restart: unless-stopped
# Allow full 30s graceful shutdown
stop_grace_period: 35s
ulimits:
nofile:
soft: 4096 # One fd per node connection + SQLite handles
hard: 8192
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 512m # Increase to 1g for 16+ node fleets
cpus: "2.0"
reservations:
memory: 128m
cpus: "0.5"
# Traefik labels for reverse proxy with TLS
# Uncomment and customize for your Traefik setup
labels:
- "traefik.enable=${TRAEFIK_ENABLE:-false}"
# - "traefik.http.routers.spaxel.rule=Host(`spaxel.example.com`)"
# - "traefik.http.routers.spaxel.entrypoints=websecure"
# - "traefik.http.routers.spaxel.tls.certresolver=letsencrypt"
# - "traefik.http.services.spaxel.loadbalancer.server.port=8080"
# # Extend Traefik timeout for long-lived WebSocket connections
# - "traefik.http.routers.spaxel.middlewares=spaxel-ws-timeout"
# - "traefik.http.middlewares.spaxel-ws-timeout.headers.respondingTimeouts.readTimeout=3600s"
volumes:
spaxel-data:
driver: local