Skip to content

Run with Docker

The repository ships a multi-stage Dockerfile with development and production targets, orchestrated through docker compose and a Makefile.

Terminal window
make setup # copies .env.example -> .env, creates dirs, creates the caddy network
make dev # development container with hot reload
make prod # production container, detached
make logs # follow container logs
development production
Dependencies full, including dev group --no-dev
Source volume-mounted read-only, hot reload baked into the image
Server command fastmcp run src/freecad_server.py:app --reload cad-mcp-server console script
User root non-root appuser

Both stages start Xvfb (a virtual display) before the server, because FreeCAD’s GUI-adjacent modules expect a display even headless. Both install dependencies with uv sync --frozen against the lock file for reproducible builds.

By default the image builds without FreeCAD:

Terminal window
docker compose build # ezdxf tier only
docker compose build --build-arg WITH_FREECAD=1 # full FreeCAD tier

This is deliberate. The apt FreeCAD install is heavyweight and fragile in constrained build environments, and the server degrades gracefully without it: the six DXF analysis tools work fully, and FreeCAD-backed tools return a per-call error like "FreeCAD not available" instead of failing at startup. If your workload is civil DXF analysis, the default image is smaller, builds faster, and does everything you need.

The base image is pinned to python:3.11-slim-bookworm because the apt freecad-python3 package targets system Python 3.11 — the venv ABI has to match.

Reading DWG input needs libredwg’s dwg2dxf, and Debian does not package it. Building from source is not something to inflict on every image, so it is a second build argument:

Terminal window
docker compose build --build-arg WITH_DWG=1 # + DWG input
docker compose build --build-arg WITH_FREECAD=1 --build-arg WITH_DWG=1 # everything

The two arguments are independent. WITH_DWG=1 fetches the pinned libredwg release, configures it static with bindings disabled, builds it, and verifies dwg2dxf --version in the same layer so a broken build fails loudly instead of shipping.

The graceful-degradation story matches FreeCAD’s: without the argument, DWG input returns a clean error naming the missing binary, and every DXF path is untouched. Outside Docker nothing needs building — a system-installed dwg2dxf is picked up from PATH automatically, or point DWG2DXF_PATH at a specific binary. See Handle DWG for what conversion actually does to a file.

The service exposes port 8000 on the external caddy network and carries caddy-docker-proxy labels:

expose:
- "8000"
networks:
- caddy
labels:
caddy: ${DOMAIN}
caddy.reverse_proxy: "{{upstreams 8000}}"

No ports: mapping — the reverse proxy is the only way in. Set DOMAIN in .env, and the front proxy handles TLS. The HTTP transport is configured with stateless JSON responses specifically so this proxied setup works with remote MCP clients (see Configuration).

Environment variables passed through compose:

environment:
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-100}
- TEMP_DIR=/app/temp
- OUTPUT_DIR=/app/output
- LOG_LEVEL=${LOG_LEVEL:-INFO}

Volumes map ./data, ./output, and ./temp into the container, so files you drop in data/ are visible to tools at /app/data/..., and rendered SVGs and conversions land in output/ on the host.

The compose file defines a TCP socket check against port 8000 — protocol-appropriate for an MCP server, where there’s no conventional HTTP health path:

healthcheck:
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 8000), timeout=3).close()"]

The integration tests that need real FreeCAD only run inside the container:

Terminal window
make test-in-docker

See Run the tests for the full two-tier testing story.