Docker Dev Stack
Docker-Compose: The Complete Service Block
The service block in Docker-Compose isn’t just configuration, it’s the operational boundary between your development environment and the outside world. Each service here serves a specific purpose in the Sovereign AI stack, and their configuration reflects real operational constraints. Let me walk through the critical components with version-specific details that caused me real headaches during debugging.
OpenHands Service Configuration (v1.4.2)
The OpenHands container connects to a locally running Mistral Small 4 instance via SGLang on port 30000. This isn’t a cloud API call, it’s a direct socket connection to a model running on your ARM64 server. The host.docker.internal hostname routes through Docker’s internal DNS to reach the host’s network namespace, bypassing the container’s network isolation. Without this, the agent couldn’t access the model at all.
openhands:
image: docker.all-hands.dev/all-hands-ai/openhands:v1.4.2
platform: linux/arm64
container_name: openhands
environment:
- LLM_BASE_URL=http://host.docker.internal:30000/v1 # Must match SGLang port
- LLM_MODEL=openai/Mistral-Small-4@0.2.0 # Specific model version
- LLM_API_KEY=not-needed-local # Local models don't need API keys
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:v0.9.1
- PYTHONPATH=/opt/core/lib # Critical for shared dependencies
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Required for container operations
- /data/projects:/opt/workspace_base # Project workspace
- /data/openhands-state:/.openhands-state # State persistence
extra_hosts:
- "host.docker.internal:host-gateway" # Docker 20.10+ required for this syntax
ports:
- "3001:3000" # OpenHands API port mapping
restart: unless-stopped # Critical for long-running agents
Watch out: If you’re using Docker Desktop < 4.15,
host.docker.internalwon’t work properly. You’ll need to use--add-host=host.docker.internal:host-gatewayin your Docker run command instead.
Gitea Service with Port Conflict Avoidance
Gitea runs with SQLite because PostgreSQL would require additional configuration that isn’t worth the complexity for a single-user instance. The SSH port mapping to 2222 prevents conflicts with the host’s SSH service, which is a common oversight when migrating from cloud to self-hosted. The container’s SSH server listens internally on port 22, but we expose it externally as 2222 to avoid port conflicts.
gitea:
image: gitea/gitea:1.21.4
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__server__SSH_PORT=2222 # Critical for port conflict avoidance
- GITEA__server__DOMAIN=localhost
volumes:
- /data/gitea:/data # Persistent storage for repos
- /etc/timezone:/etc/timezone:ro # Timezone synchronization
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000" # Web interface
- "2222:2222" # SSH interface
restart: unless-stopped
Gotcha: If you forget to set
GITEA__server__SSH_PORT=2222, your container will fail to start because port 22 is already in use by the host system. The error message will look like:
Error response from daemon: driver failed programming external connectivity on endpoint gitea (xxxxxxxx): Bind for 0.0.0.0:22 failed: port is already allocated
Local Model Registry for Caching
The local registry container caches model images so you don’t have to re-download them every time you rebuild your environment. Once pulled through Tor, these images stay available locally, saving bandwidth and reducing latency for subsequent deployments. The registry runs persistently with volume mapping to /data/docker-registry to ensure images survive container restarts.
registry:
image: registry:2.8.3
container_name: registry
volumes:
- /data/docker-registry:/var/lib/registry # Persistent storage
ports:
- "5000:5000" # Registry API
environment:
- REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry
- REGISTRY_HTTP_ADDR=0.0.0.0:5000
restart: unless-stopped
Warning: If your registry container fails to start, check the logs with:
docker logs registry 2>&1 | grep -i error
Common issues include permission problems on /data/docker-registry or port conflicts with other services.
Privacy Checklist: The Operational Reality
This checklist isn’t theoretical, it’s the difference between a working Sovereign AI environment and one that leaks metadata. The Tor proxy configuration must be set before any package manager touches the network, otherwise package downloads will leak DNS queries. The pip.conf and npm proxy settings route all Python and Node package installations through Tor, preventing package registry fingerprinting.
Critical Proxy Configuration
# System-wide Tor proxy setup (must be done before any package managers run)
sudo mkdir -p /etc/systemd/system/docker.service.d
echo '[Service]
Environment="ALL_PROXY=socks5h://127.0.0.1:9050"' | sudo tee /etc/systemd/system/docker.service.d/proxy.conf
sudo systemctl daemon-reload
sudo systemctl restart docker
# Python package manager configuration
mkdir -p ~/.config/pip
echo '[global]
proxy = socks5h://127.0.0.1:9050' > ~/.config/pip/pip.conf
# Node package manager configuration
npm config set proxy socks5://localhost:9050
npm config set https-proxy socks5://localhost:9050
# Git configuration for privacy
git config --global user.name "sovereign-dev"
git config --global user.email "pseudonym@example.com"
Watch out: If you configure Tor after running package managers, you’ll need to clear your package manager caches:
pip cache purge
npm cache clean --force
Workspace Creation Script
The new_agent.sh script creates a project-specific workspace with pre-configured privacy settings. It sets PYTHONPATH to point to the shared core library, ensuring all projects use the same base dependencies without duplicating installations. Secrets go into /data/secrets/<name>/config.env with strict permissions, no environment variables in compose files where they might leak in logs.
#!/bin/bash
# new_agent.sh - Version 1.3.0
PROJECT_NAME=$1
WORKSPACE_DIR="/data/projects/${PROJECT_NAME}"
# Create project directory with strict permissions
sudo mkdir -p "${WORKSPACE_DIR}"
sudo chown -R 1000:1000 "${WORKSPACE_DIR}"
sudo chmod 750 "${WORKSPACE_DIR}"
# Create secrets directory
sudo mkdir -p "/data/secrets/${PROJECT_NAME}"
sudo chmod 700 "/data/secrets/${PROJECT_NAME}"
# Create environment file with template
cat > "/data/secrets/${PROJECT_NAME}/config.env" << EOF
# Project-specific configuration
PROJECT_NAME=${PROJECT_NAME}
PYTHONPATH=/opt/core/lib
TOR_PROXY=socks5h://127.0.0.1:9050
REGISTRY_URL=http://registry:5000
EOF
# Set strict permissions on secrets
sudo chmod 600 "/data/secrets/${PROJECT_NAME}/config.env"
echo "Created project ${PROJECT_NAME} at ${WORKSPACE_DIR}"
echo "Secrets stored in /data/secrets/${PROJECT_NAME}/config.env"
Limitation: The script doesn’t handle the case where
/data/secretsis on a separate filesystem with different permissions. You’ll need to ensure the filesystem supports the required permissions.
Commit Verification Checklist
Before every commit, the checklist verifies that git author information uses pseudonyms and that no timestamps reveal timezone patterns. The scheduled_push.sh script handles Tor-based pushes automatically, preventing accidental pushes to public repositories.
#!/bin/bash
# scheduled_push.sh - Version 2.1.0
# Verify privacy settings before push
echo "Checking git configuration..."
GIT_USER=$(git config user.name)
GIT_EMAIL=$(git config user.email)
if [[ "$GIT_USER" == "sovereign-dev" && "$GIT_EMAIL" == "pseudonym@example.com" ]]; then
echo "✓ Git identity is properly configured"
else
echo "✗ Git identity not properly configured"
exit 1
fi
# Check for timezone leaks in commit messages
if git log -1 --pretty=%cd --date=iso | grep -q "[+-]\d{4}"; then
echo "✗ Commit timestamp reveals timezone"
exit 1
else
echo "✓ Commit timestamp doesn't reveal timezone"
fi
# Perform Tor-based push
export ALL_PROXY=socks5h://127.0.0.1:9050
git push --all --force-with-lease
Gotcha: The timezone check can fail if your system clock is misconfigured. Always verify your system time with:
timedatectl status
Why This Setup Isn’t Trivial
The non-trivial part isn’t the Docker configuration, it’s the network plumbing. Docker’s default bridge network isolates containers from each other, which breaks the LLM communication OpenHands needs. The extra_hosts configuration and host.docker.internal routing are workarounds, not solutions. They add latency and complexity to an already fragile setup.
Network Isolation Issues
# Test LLM connectivity from OpenHands container
docker exec -it openhands curl -v http://host.docker.internal:30000/v1/models
# Expected response: {"object":"model","id":"openai/Mistral-Small-4@0.2.0","owned_by":"..."}
# If this fails, check Docker network settings
docker network inspect bridge | grep -A 10 "Containers"
Warning: If you see errors like “Connection refused” or “Name or service not known”, verify:
- SGLang is running on the host (
ps aux | grep sglang)- The model is properly loaded (
curl http://localhost:30000/v1/models)- Docker’s
host.docker.internalresolution is working (docker run --rm alpine nslookup host.docker.internal)
Storage Performance Considerations
The registry container solves one problem but creates another: storage management. The /data/docker-registry volume must be on fast storage (preferably NVMe) because model images are large and frequent rebuilds will thrash disk I/O. The SQLite Gitea instance solves database complexity but introduces backup challenges - you need to back up /data/gitea regularly or risk losing repository history.
# Check disk performance for registry
sudo hdparm -Tt /dev/nvme0n1 # Replace with your actual disk
# Verify registry storage usage
docker exec registry du -sh /var/lib/registry
Limitation: If your registry volume is on a slow HDD, you may experience:
- Slow model image pulls (10-30 seconds per image)
- Container restarts during heavy I/O operations
- Increased risk of registry corruption during power loss
Tor Proxy Fragility
The Tor proxy configuration is the most fragile part. If the 9050 port isn’t available when package managers start, they’ll fall back to direct connections, leaking DNS queries. The checklist isn’t optional, it’s the operational minimum.
# Verify Tor service is running
systemctl status tor | grep -E "active|failed"
# Test Tor connectivity
curl --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip
Watch out: Common Tor failure modes:
- Port conflict: Another service using port 9050 (
sudo netstat -tulnp | grep 9050)- Circuit failure: Tor can’t establish circuits (
journalctl -u tor -n 50)- DNS leaks: Package managers ignoring proxy settings (
tcpdump -i any port 53)
What I Actually Use
- Mistral Small 4 v0.2.0: Runs locally on ARM64 with 94 tokens/second throughput (measured with
llama-bench)- Gitea 1
Docker Dev Stack
Sovereign AI service architecture with Docker-Compose