Dockerizing Everything: Our Deployment Pipeline
Every Sorren.ai service — Companion API, vector database, inference server, web frontend — is now containerized. Multi-stage builds, health checks, Docker Compose for dev, and the road toward real CI/CD.
Saturday, late morning, the whole stack is containerized and I can rebuild the entire Sorren.ai backend from a clean checkout with one command. This is the post about how we got there.
The pieces: the Companion API, the pgvector database, the local inference server, the web frontend. Until recently these were a mix of systemd services and "it works on the dev box" assumptions. That works for two people in a room. It stops working when you need to reproduce an environment, onboard someone, or deploy to a fresh server.
The answer is containers. The answer is always containers.
The Principle: Reproducibility
Every environment — my Kubuntu desktop, the Proxmox VMs, eventually a cloud host — should run the exact same image. Not "similar configurations." The same bytes. If it works in dev, it works in staging, it works in production. Configuration differences live in environment variables, not in the image.
This is the point of Docker: the death of "works on my machine." If the container starts locally, it starts everywhere.
Multi-Stage Builds
Multi-stage builds separate the build environment from the runtime. A naive Dockerfile copies the entire project in, installs all dev dependencies, ships the bloated result. Multi-stage avoids that:
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
RUN uv build
# Runtime stage
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/dist/*.whl .
RUN pip install *.whl && rm -rf *.whl
RUN useradd -m -u 1000 sorren
USER sorren
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "sorren.api:app", "--host", "0.0.0.0", "--port", "8000"]
Key choices:
- Separate build and runtime. The final image doesn't have
uv, doesn't have the source tree, doesn't have dev dependencies. It has the installed wheel and nothing else. - Non-root user. The process runs as
sorren, not root. If something goes wrong, it goes wrong with limited permissions. - Health check baked in. Docker (and any orchestrator) knows whether the service is actually serving, not just whether the process is alive.
.dockerignoreexists. No.git, nonode_modules, no local.envfiles sneaking into the build context.
The resulting image is small, starts fast, and behaves identically everywhere.
Docker Compose for Local Dev
For development, everything runs via Docker Compose. One file describes the entire stack:
# docker-compose.yml
services:
api:
build: ./services/api
ports: ["8000:8000"]
environment:
- DATABASE_URL=postgresql://sorren:${DB_PASSWORD}@db:5432/sorren
- INFERENCE_URL=http://inference:8000/v1
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
inference:
condition: service_healthy
restart: unless-stopped
db:
image: ankane/pgvector:latest
volumes: ["pgdata:/var/lib/postgresql/data"]
environment:
- POSTGRES_USER=sorren
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=sorren
healthcheck:
test: ["CMD", "pg_isready", "-U", "sorren"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
inference:
image: vllm/vllm-openai:latest
runtime: nvidia
volumes: ["./models:/root/.cache/huggingface"]
command: >
--model bge-large-en-v1.5
--task embed
deploy:
resources:
reservations:
devices:
- driver: nvidia
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 10s
retries: 3
restart: unless-stopped
web:
build: ./services/web
ports: ["3000:3000"]
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000
depends_on:
- api
restart: unless-stopped
volumes:
pgdata:
A new developer — or me, on a fresh machine — clones the repo, creates a .env with the passwords, runs docker compose up, and has the entire stack running. The API talks to the database and inference server over the Compose network. The database is pgvector-enabled out of the box. The inference server has the GPU. The web frontend talks to the API.
No manual setup. No "install PostgreSQL and enable this extension." No "run these three commands in different terminals." One command, full stack, reproducible.
Health Checks and Dependency Ordering
Notice depends_on with condition: service_healthy. Without it, the API starts before the database is ready, fails to connect, crashes, restarts — and you spend ten minutes reading connection errors.
With health checks defined on each service, Compose waits. The API doesn't start until the database passes pg_isready. The web frontend doesn't start until the API responds on /health. The stack comes up in order, cleanly. Define health checks on everything. Make startup ordering explicit. Don't rely on timing and luck.
Secrets
Secrets — database passwords, API keys — live in a .env file that is .gitignored. Compose reads it automatically. For production, the same variables come from the host environment or a secrets manager. The containers never change; only the environment does.
# .env (never committed)
DB_PASSWORD=...
HF_TOKEN=...
OPENAI_API_KEY=...
I keep a .env.example in the repo with dummy values, so the required variables are documented without leaking anything real.
The CI/CD Road
Local reproducibility is the foundation. Next is CI/CD — automated builds on every push, image publishing to a registry, deployment without a human SSHing into anything.
We're not there yet. Builds are still manual: docker compose build, push to registry, pull on the server. It works, but it's the kind of process that invites mistakes. The goal is a pipeline where a merge to main triggers a build, runs tests, deploys — without my intervention. The containers make that possible. Every step operates on the same artifact. The image is the contract.
Where This Leaves Us
The entire Sorren.ai backend is now containerized. I can destroy every VM and rebuild from a clean repository in under an hour — most of which is model download time. That's not just convenient. It's the difference between infrastructure that survives its creator and infrastructure that doesn't.
Next milestones: CI/CD automation, image scanning, automated rollbacks, and eventually orchestration beyond a single host. Docker Swarm or Kubernetes, when we need it. For now, Compose on a beefy Proxmox VM is enough, and "enough" is the right amount of complexity.
Saturday well spent. The containers are running. Time to close the laptop.