Lightweight process containerization for macOS Apple Silicon with Metal/MLX support
Project description
MetalBox
Lightweight process containerization for macOS Apple Silicon. Run ML workloads with Metal/MLX acceleration and Docker-like resource limits — without a Linux VM.
The problem
Every container runtime on macOS (Docker, Podman, OrbStack, Lima) runs a Linux VM. Linux doesn't have Metal. So you can't use MLX, MPS, or any Metal-accelerated framework inside a container. You're stuck choosing between:
- Docker — real resource limits, but no GPU, 3x slower for ML inference
- Native — full Metal speed, but no resource limits, no lifecycle management, dangerous on a shared machine
MetalBox gives you both: native Metal performance with container-like resource management.
How it works
MetalBox is a Go server that runs your workloads as native macOS processes with enforced resource limits, health checks, and a web dashboard for monitoring.
┌──────────────────────────────────────┐
│ metalbox-dashboard (Go binary) │
│ │
│ ┌────────────┐ ┌────────────────┐ │
│ │ your app │ │ resource guard │ │
│ │ (native │◄─│ • RSS watchdog │ │
│ │ macOS │ │ • Metal mem cap│ │
│ │ process) │ │ • CPU policy │ │
│ └────────────┘ └────────────────┘ │
│ │ │ │
│ Metal / MLX / MPS │ health checks│
│ (direct GPU access) │ log capture │
│ │ auto-restart │
│ ┌─────────────────────────────────┐ │
│ │ web dashboard (localhost:9090) │ │
│ │ start/stop/restart • logs │ │
│ │ RSS/CPU graphs • events │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
Quick start
# Install from PyPI (macOS Apple Silicon)
pip install metalbox
# Or install with uv
uv tool install metalbox
# Create a metalbox.yml (see Config below), then:
metalbox serve
# Open http://localhost:9090
Web dashboard
The dashboard runs on localhost:9090 and provides:
- Service overview — status, PID, RSS, CPU%, memory usage bars
- GPU memory monitoring — active/peak/cache usage for Metal/MLX workloads
- Resource history sparklines — inline charts showing RSS trends over time
- Start / Stop / Restart buttons per service
- Log viewer with auto-refresh
- Event timeline — starts, stops, OOM kills, health check failures, restarts
- Auto-refresh every 3 seconds
CLI
A thin Python CLI is also available, talking to the dashboard API:
# Install globally
pip install metalbox
# or
uv tool install metalbox
metalbox serve # start the dashboard server + web UI
metalbox serve -d # start in background (detached)
metalbox ps # show services + resource usage
metalbox start myapp # start a service
metalbox stop myapp # stop a service
metalbox restart myapp # restart a service
metalbox logs myapp # view logs
# One-shot execution (no server, no config file needed)
metalbox run --memory 2g "python train.py"
metalbox run --memory 512m --metal-memory 1g "python -m uvicorn app:app"
metalbox run --cpus background "python bench.py"
metalbox run
Run any command with resource limits — no YAML config or dashboard server needed. MetalBox starts the process, enforces limits, and exits when it finishes.
metalbox run [OPTIONS] "command"
Options:
-m, --memory RSS memory limit (e.g. 2g, 512m)
--metal-memory Metal GPU memory limit
--metal-cache Metal cache limit
--cpus CPU policy: default or background
--name Service name (default: derived from command)
Config
metalbox.yml in your project directory:
services:
inference:
command: python -m uvicorn app:app --host 0.0.0.0 --port 8080
workdir: /path/to/your/project
env:
MODEL_CACHE: /tmp/models
resources:
memory: 2.5g # hard RSS limit — process killed + restarted if exceeded
metal_memory: 2g # Metal heap cap (mx.metal.set_memory_limit)
metal_cache: 512m # Metal cache cap (mx.metal.set_cache_limit)
cpus: background # "background" = E-cores only, "default" = all cores
restart: unless-stopped # always | unless-stopped | on-failure | no
healthcheck:
url: http://127.0.0.1:8080/healthz
interval: 30
timeout: 10
retries: 3
start_period: 120
proxy:
command: caddy reverse-proxy --from :443 --to :8080
ports: [443] # fail-fast if port already in use
resources:
memory: 128m
restart: always
depends_on: # start after these services are healthy
- inference
sandbox: # filesystem + network isolation (macOS sandbox-exec)
preset: binary # runtime preset: python | node | binary
allow_net: true # allow network access (default: false)
localhost_only: true # restrict to localhost only (default: false)
read_only: # can read but not write these paths
- /etc/config
read_write: # additional writable paths (workdir + /tmp always allowed)
- /var/data
deny_exec: true # block spawning new processes (default: false)
Process sandbox
MetalBox uses macOS sandbox-exec (Seatbelt) for process isolation. Two modes:
Permissive (default) — allow everything, deny filesystem writes outside workdir:
sandbox:
allow_net: true
Strict (deny-default, Docker-like) — deny everything, explicitly allow only what's needed. Enabled automatically when using a preset, or set strict: true manually:
sandbox:
preset: python # auto-configures for Python/MLX/uv runtimes
allow_net: true
localhost_only: true # can only talk to localhost, not the internet
Runtime presets
| Preset | What it allows |
|---|---|
python |
pyenv, venv, conda, uv, pip, huggingface/mlx model caches |
node |
nvm, npm, yarn, pnpm |
binary |
Minimal — only system libs. For compiled Go/Rust/C binaries |
What strict mode blocks by default
- Sensitive paths —
~/.ssh,~/.aws,~/.gnupg,~/.docker,~/.kube,~/.pypirc,~/.netrc(no credential leaks) - Filesystem writes — only workdir, logs, tmp, and caches are writable
- IPC — restricts shared memory to Apple system services only
- Network — denied unless
allow_net: true; addlocalhost_only: trueto block external connections
You can add custom deny paths:
sandbox:
strict: true
deny_paths:
- /Users/me/secrets
- /Users/me/.env
Service dependencies
depends_on delays a service's start until its dependencies are healthy. If a dependency has a healthcheck, MetalBox waits for it to pass before starting the dependent service. If there's no healthcheck, it waits for the process to be running.
services:
api:
command: python -m uvicorn app:app --port 8080
healthcheck:
url: http://127.0.0.1:8080/healthz
interval: 10
retries: 3
start_period: 60
proxy:
command: caddy reverse-proxy --from :443 --to :8080
depends_on:
- api # proxy won't start until api's healthcheck passes
Environment isolation
By default, services get a clean environment — only essential system vars (PATH, HOME, USER, SHELL, LANG, TERM, TMPDIR) plus your declared env: vars. No inherited AWS_*, GITHUB_*, SSH_*, or other secrets leak in.
To opt into full parent env inheritance, set env_inherit: true on a service.
How resource limits work
| Resource | Mechanism | Hard? |
|---|---|---|
| Memory (RSS) | Go watchdog polls footprint every 5s across the full process tree — kills + auto-restarts if exceeded |
Yes (kill + restart) |
| Metal memory | Wrapper injection: auto-generates a Python script calling mx.metal.set_memory_limit() before your app imports anything |
Yes (Metal API) |
| Metal cache | Same wrapper calls mx.metal.set_cache_limit() |
Yes (Metal API) |
| CPU | taskpolicy -b for background QoS (E-cores only), or default (all cores) |
Structural (not %) |
| Health checks | HTTP GET / TCP connect / shell command — restart on consecutive failures | Configurable retries |
macOS has no cgroups. RSS limits (ulimit -m) are silently ignored by the kernel. MetalBox enforces memory limits by monitoring RSS across the entire process tree (using macOS footprint for accurate measurement) and killing if exceeded — then auto-restarting per the configured restart policy.
GPU monitoring
For Metal/MLX workloads, the dashboard shows real-time GPU memory usage (active, peak, and cache) alongside RSS. A background reporter thread in the Metal wrapper writes stats to ~/.metalbox/stats/ every 5 seconds, which the dashboard picks up automatically.
The dashboard also renders resource history sparklines — inline SVG charts showing RSS trends over the last 60 samples, so you can spot memory leaks or load patterns at a glance.
Metal memory injection
For Python/MLX workloads, MetalBox auto-generates a wrapper script that calls mx.metal.set_memory_limit() and mx.metal.set_cache_limit() before your app loads any models. The wrapper is transparent — it detects python -m module and python script.py patterns (including uv run python prefix) and rewrites the command to go through the wrapper first.
Architecture
metalbox/
├── dashboard/
│ ├── main.go # Go server — process supervisor, RSS guard,
│ │ # health checks, Metal wrapper, REST API
│ └── static/
│ └── index.html # embedded web dashboard (single file)
├── metalbox/
│ ├── __init__.py
│ └── cli.py # thin Python CLI (talks to Go server API)
├── pyproject.toml # Python CLI package config
└── metalbox.yml # your service config (gitignored)
The Go binary is the runtime — it handles everything:
- Process lifecycle (start, stop, restart, PID files)
- RSS memory watchdog (kill + restart on exceed)
- Metal/MLX memory limit injection (auto-generated Python wrapper)
- CPU policy via
taskpolicy - Health checks (HTTP, TCP, command)
- Log capture to
~/.metalbox/logs/ - Web dashboard + REST API
- Event tracking (OOM kills, health failures, restarts)
The Python CLI is optional — a thin client that calls the REST API for terminal use.
Comparison
| Docker | MetalBox | Native script | |
|---|---|---|---|
| Metal / MLX / MPS | No | Yes | Yes |
| Memory limits | Hard (cgroups) | Hard (watchdog + kill) | None |
| CPU limits | Hard (cgroups) | Structural (E-cores) | None |
| Health checks | Yes | Yes (HTTP/TCP/CMD) | None |
| Web dashboard | Docker Desktop | Yes (localhost:9090) | None |
| Lifecycle management | Full | Yes | Manual |
| Filesystem isolation | Full (namespaces) | Write restrictions (sandbox-exec) | None |
| Network isolation | Full (namespaces) | Yes (sandbox-exec) | None |
| Environment isolation | Yes | Yes (clean env by default) | None |
| Port conflict detection | Yes | Yes (fail-fast) | None |
| Works on Linux | Yes | No (macOS only) | No |
Installation
# From PyPI (recommended — includes pre-built Go binary)
pip install metalbox
# Or with uv
uv tool install metalbox
# From source
git clone https://github.com/ronxldwilson/metalbox.git
cd metalbox
./build.sh # builds Go binary + Python wheel
pip install dist/*.whl
Pre-built wheels are published for both Apple Silicon (arm64) and Intel (x86_64) Macs. pip install automatically picks the right one for your architecture.
Requirements
- macOS 14+ on Apple Silicon
- Go 1.21+ (only needed if building from source)
- Python 3.10+ (optional, for CLI only)
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file metalbox-0.4.0-py3-none-macosx_14_0_arm64.whl.
File metadata
- Download URL: metalbox-0.4.0-py3-none-macosx_14_0_arm64.whl
- Upload date:
- Size: 3.0 MB
- Tags: Python 3, macOS 14.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d563dd039fb49699e3af39668ec22d60d0a63ab1dda451623c16823a93ac5f12
|
|
| MD5 |
6c2d14e88edd1796375a38e94e8585f5
|
|
| BLAKE2b-256 |
2f696c0972623f61bc05d7b763bd114e8917cd0d6b57649e126f334a31a62cfa
|