Skip to content

Commit c5ee9c2

Browse files
committed
Add Docker production setup with PostgreSQL and MCP servers
- Dockerfile: Multi-stage build with all database drivers and optional dependencies (serve, mcp, all-databases). 781MB runtime image. - docker-entrypoint.sh: Single container with SIDEMANTIC_MODE env var to control serve/mcp/both modes - .dockerignore: Excludes unnecessary files for faster builds - Add --host parameter to sidemantic serve CLI and config for container networking (0.0.0.0 support) - README: Comprehensive Docker usage section with examples Server fully tested: psql connections work, semantic layer tables queryable, demo mode verified.
1 parent a5e90db commit c5ee9c2

7 files changed

Lines changed: 195 additions & 5 deletions

File tree

.dockerignore

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.git
2+
.github
3+
.venv
4+
.mypy_cache
5+
.pytest_cache
6+
.ruff_cache
7+
__pycache__
8+
*.egg-info
9+
*.pyc
10+
11+
# Rust/DuckDB extension builds (not needed for Python image)
12+
sidemantic-rs/
13+
sidemantic-duckdb/
14+
15+
# Docs and examples not needed at runtime
16+
docs/
17+
examples/superset_demo/
18+
examples/rill_demo/
19+
examples/cube_demo/
20+
21+
# Dev/test artifacts
22+
Dockerfile.test
23+
docker-compose.yml
24+
*.md
25+
!README.md
26+
.context/

Dockerfile

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
FROM python:3.12-slim AS builder
2+
3+
# Install build deps for riffq (Rust/maturin) and other native extensions
4+
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
build-essential \
6+
cmake \
7+
pkg-config \
8+
libssl-dev \
9+
&& rm -rf /var/lib/apt/lists/*
10+
11+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
12+
13+
WORKDIR /app
14+
15+
COPY pyproject.toml README.md LICENSE ./
16+
COPY sidemantic/ sidemantic/
17+
COPY examples/ examples/
18+
19+
RUN uv pip install --system --no-cache ".[serve,mcp,all-databases]"
20+
21+
# --- Runtime stage (no build tools) ---
22+
FROM python:3.12-slim
23+
24+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
25+
26+
# Copy installed packages from builder
27+
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
28+
COPY --from=builder /usr/local/bin/sidemantic /usr/local/bin/sidemantic
29+
30+
WORKDIR /app
31+
32+
COPY docker-entrypoint.sh /docker-entrypoint.sh
33+
RUN chmod +x /docker-entrypoint.sh
34+
35+
RUN mkdir -p /app/models
36+
WORKDIR /app/models
37+
38+
EXPOSE 5433
39+
40+
ENTRYPOINT ["/docker-entrypoint.sh"]
41+
# Mode is controlled by SIDEMANTIC_MODE env var (serve, mcp, both)

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,77 @@ load_from_directory(layer, "my_models/") # Auto-detects formats
252252
| Databricks || `uv add sidemantic[databricks]` |
253253
| Spark SQL || `uv add sidemantic[spark]` |
254254

255+
## Docker
256+
257+
Build the image (includes all database drivers, PG server, and MCP server):
258+
259+
```bash
260+
docker build -t sidemantic .
261+
```
262+
263+
### PostgreSQL server (default)
264+
265+
Mount your models directory and expose port 5433:
266+
267+
```bash
268+
docker run -p 5433:5433 -v ./models:/app/models sidemantic
269+
```
270+
271+
Connect with any PostgreSQL client:
272+
273+
```bash
274+
psql -h localhost -p 5433 -U any -d sidemantic
275+
```
276+
277+
With a backend database connection:
278+
279+
```bash
280+
docker run -p 5433:5433 \
281+
-v ./models:/app/models \
282+
-e SIDEMANTIC_CONNECTION="postgres://user:pass@host:5432/db" \
283+
sidemantic
284+
```
285+
286+
### MCP server
287+
288+
```bash
289+
docker run -v ./models:/app/models -e SIDEMANTIC_MODE=mcp sidemantic
290+
```
291+
292+
### Both servers simultaneously
293+
294+
Runs the PG server in the background and MCP on stdio:
295+
296+
```bash
297+
docker run -p 5433:5433 -v ./models:/app/models -e SIDEMANTIC_MODE=both sidemantic
298+
```
299+
300+
### Demo mode
301+
302+
```bash
303+
docker run -p 5433:5433 sidemantic --demo
304+
```
305+
306+
### Baking models into the image
307+
308+
Create a `Dockerfile` that copies your models at build time:
309+
310+
```dockerfile
311+
FROM sidemantic
312+
COPY my_models/ /app/models/
313+
```
314+
315+
### Environment variables
316+
317+
| Variable | Description |
318+
|----------|-------------|
319+
| `SIDEMANTIC_MODE` | `serve` (default), `mcp`, or `both` |
320+
| `SIDEMANTIC_CONNECTION` | Database connection string |
321+
| `SIDEMANTIC_DB` | Path to DuckDB file (inside container) |
322+
| `SIDEMANTIC_USERNAME` | PG server auth username |
323+
| `SIDEMANTIC_PASSWORD` | PG server auth password |
324+
| `SIDEMANTIC_PORT` | PG server port (default 5433) |
325+
255326
## Testing
256327

257328
```bash

docker-entrypoint.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/sh
2+
set -e
3+
4+
# SIDEMANTIC_MODE: "serve" (default), "mcp", or "both"
5+
MODE="${SIDEMANTIC_MODE:-serve}"
6+
7+
# Build shared args from environment variables
8+
ARGS=""
9+
if [ -n "$SIDEMANTIC_CONNECTION" ]; then
10+
ARGS="$ARGS --connection $SIDEMANTIC_CONNECTION"
11+
fi
12+
if [ -n "$SIDEMANTIC_DB" ]; then
13+
ARGS="$ARGS --db $SIDEMANTIC_DB"
14+
fi
15+
16+
# Serve-specific args
17+
SERVE_ARGS=""
18+
if [ -n "$SIDEMANTIC_USERNAME" ]; then
19+
SERVE_ARGS="$SERVE_ARGS --username $SIDEMANTIC_USERNAME"
20+
fi
21+
if [ -n "$SIDEMANTIC_PASSWORD" ]; then
22+
SERVE_ARGS="$SERVE_ARGS --password $SIDEMANTIC_PASSWORD"
23+
fi
24+
if [ -n "$SIDEMANTIC_PORT" ]; then
25+
SERVE_ARGS="$SERVE_ARGS --port $SIDEMANTIC_PORT"
26+
fi
27+
28+
case "$MODE" in
29+
serve)
30+
exec sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS "$@"
31+
;;
32+
mcp)
33+
exec sidemantic mcp-serve $ARGS "$@"
34+
;;
35+
both)
36+
# Start PG server in background, MCP on stdio in foreground
37+
sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS &
38+
SERVE_PID=$!
39+
trap "kill $SERVE_PID 2>/dev/null" EXIT
40+
exec sidemantic mcp-serve $ARGS "$@"
41+
;;
42+
*)
43+
echo "Unknown SIDEMANTIC_MODE: $MODE (use serve, mcp, or both)" >&2
44+
exit 1
45+
;;
46+
esac

sidemantic/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ def serve(
422422
None, "--connection", help="Database connection string (e.g., postgres://host/db, bigquery://project/dataset)"
423423
),
424424
db: Path = typer.Option(None, "--db", help="Path to DuckDB database file (shorthand for duckdb:/// connection)"),
425+
host: str = typer.Option(None, "--host", "-H", help="Host/IP to bind to (overrides config, default 127.0.0.1)"),
425426
port: int = typer.Option(None, "--port", "-p", help="Port to listen on (overrides config)"),
426427
username: str = typer.Option(None, "--username", "-u", help="Username for authentication (overrides config)"),
427428
password: str = typer.Option(None, "--password", help="Password for authentication (overrides config)"),
@@ -482,7 +483,8 @@ def serve(
482483
# Use connection from config
483484
connection_str = build_connection_string(_loaded_config)
484485

485-
# Resolve port, username, password from args or config
486+
# Resolve host, port, username, password from args or config
487+
host_resolved = host or (_loaded_config.pg_server.host if _loaded_config else "127.0.0.1")
486488
port_resolved = port if port is not None else (_loaded_config.pg_server.port if _loaded_config else 5433)
487489
username_resolved = username or (_loaded_config.pg_server.username if _loaded_config else None)
488490
password_resolved = password or (_loaded_config.pg_server.password if _loaded_config else None)
@@ -535,7 +537,7 @@ def serve(
535537
layer.conn.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows)
536538

537539
# Start the server
538-
start_server(layer, port=port_resolved, username=username_resolved, password=password_resolved)
540+
start_server(layer, host=host_resolved, port=port_resolved, username=username_resolved, password=password_resolved)
539541

540542

541543
@app.command(hidden=True)

sidemantic/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class PostgresServerConfig(BaseModel):
104104
This feature is experimental and may change.
105105
"""
106106

107+
host: str = Field(default="127.0.0.1", description="Host/IP to bind to (use 0.0.0.0 for Docker)")
107108
port: int = Field(default=5433, description="Port to listen on")
108109
username: str | None = Field(default=None, description="Username for authentication (optional)")
109110
password: str | None = Field(default=None, description="Password for authentication (optional)")

sidemantic/server/server.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def map_type(duckdb_type: str) -> str:
3333

3434
def start_server(
3535
layer: SemanticLayer,
36+
host: str = "127.0.0.1",
3637
port: int = 5433,
3738
username: str | None = None,
3839
password: str | None = None,
@@ -41,6 +42,7 @@ def start_server(
4142
4243
Args:
4344
layer: Semantic layer instance
45+
host: Host/IP to bind to (use 0.0.0.0 for Docker)
4446
port: Port to listen on
4547
username: Username for authentication (optional)
4648
password: Password for authentication (optional)
@@ -52,7 +54,7 @@ def __init__(self, connection_id, executor):
5254
super().__init__(connection_id, executor, layer, username, password)
5355

5456
# Start server
55-
server = riffq.RiffqServer(f"127.0.0.1:{port}", connection_cls=BoundConnection)
57+
server = riffq.RiffqServer(f"{host}:{port}", connection_cls=BoundConnection)
5658

5759
# Register catalog
5860
typer.echo("Registering semantic layer catalog...", err=True)
@@ -134,12 +136,13 @@ def __init__(self, connection_id, executor):
134136
server._server.register_table("sidemantic", "semantic_layer", "metrics", metric_columns)
135137
typer.echo(" Registered table: semantic_layer.metrics", err=True)
136138

137-
typer.echo(f"\nStarting PostgreSQL-compatible server on 127.0.0.1:{port}", err=True)
139+
typer.echo(f"\nStarting PostgreSQL-compatible server on {host}:{port}", err=True)
138140
if username:
139141
typer.echo(f"Authentication: username={username}", err=True)
140142
else:
141143
typer.echo("Authentication: disabled (any username/password accepted)", err=True)
142-
typer.echo(f"\nConnect with: psql -h 127.0.0.1 -p {port} -U {username or 'any'} -d sidemantic\n", err=True)
144+
connect_host = "localhost" if host == "0.0.0.0" else host
145+
typer.echo(f"\nConnect with: psql -h {connect_host} -p {port} -U {username or 'any'} -d sidemantic\n", err=True)
143146

144147
# Disable catalog_emulation and handle catalog queries manually in our Python handler
145148
# This prevents riffq from intercepting queries with its DataFusion parser (which fails on multi-statement queries)

0 commit comments

Comments
 (0)