DEV Community

Jude
Jude

Posted on

I tried to cut our 12-container stack down to 4. Two of my three conclusions were wrong.

Self-hosted projects lose most of their prospective users at the first command in the README. Here is ours:

docker compose --env-file .env -f docker/docker-compose.yml up -d \
  postgres redis minio etcd milvus vault migrate bootstrap api web \
  knowledge-ingest-worker outbox-dispatcher
Enter fullscreen mode Exit fullscreen mode

Twelve service names. Issue #25 asked the obvious question: I just want to look at it — do I really need all of them?

No. This post takes the twelve apart: which ones genuinely cannot go, which group leaves as a unit, which one you fold into the API process instead of deleting, and what breaks with each removal.

More importantly: I actually ran the trimmed stack. That matters, because two of the three conclusions I had drawn from reading the code turned out to be wrong. Had I published the paper version, you would have followed it and ended up with a stack that starts and then refuses to show you a UI. I left both mistakes in, because they carry more information than the correct answers do.

The short answer

Service Can it go? What it costs you
postgres No Hard readiness gate — 503 without it
minio + minio-init No (see section 2) On paper the local filesystem replaces it. In the published image that does not work.
migrate / bootstrap No One-shot jobs: schema and the admin account
api / web No They are the thing being demoed
milvus + etcd Yes, as a group — with a side effect Vector search raises at call time, and readiness gets slow enough to mark the API unhealthy
vault Yes Secrets move to an in-process store, gone on restart
redis Yes, for a demo No permission cache, single-process event bus
knowledge-ingest-worker Yes No document ingestion
outbox-dispatcher Fold it in One environment variable moves it into the API

Twelve becomes seven, and since minio-init, migrate and bootstrap exit when they finish, four containers stay running: postgres, minio, api, web.

What you save is Milvus (a 2.6GB image), etcd, Vault, Redis, the ingestion worker and the outbox container.

1. The readiness endpoint tells you which dependencies are real

The fastest way to find out whether a dependency is hard is not the deployment guide — it is the health check. A deployment guide documents intent; a health check documents behaviour.

In server/app/api/v1/health/router.py, three backends are treated differently:

try:
    db.execute(text("SELECT 1"))
    db_status = "connected"
except Exception:
    raise HTTPException(status_code=503, detail="Database is unavailable")

try:
    await storage.ensure_ready()
    storage_status = "connected"
except Exception:
    raise HTTPException(status_code=503, detail="Object storage is unavailable")

try:
    await vector.check_ready()
    vector_status = "connected"
except Exception:
    vector_status = "unavailable"
Enter fullscreen mode Exit fullscreen mode

Database or object storage down means 503. Vector store down means the field reads unavailable and the endpoint still returns 200. The docstring gives the reason: the platform degrades gracefully when the vector store is down, so a vector outage should be surfaced rather than pull the instance out of rotation.

That leaves two hard requirements: a reachable Postgres, and a writable storage root.

The second one says storage root, not MinIO — and I assumed that distinction meant MinIO could go. That is wrong conclusion number one.

2. Wrong conclusion #1: local filesystem storage does not work inside the image

The storage adapter is built on fsspec (server/app/adapters/storage/fsspec.py), and the base URL resolves in this order:

self.base_url = base_url or settings.storage_url or self._default_local_base_url()
Enter fullscreen mode Exit fullscreen mode

_default_local_base_url() returns a file:// URI under the repository root. So point STORAGE_URL at a local directory (or leave it unset) and storage should land on disk with no object store at all.

I configured exactly that, and the API came up returning 503:

{"success":false,"code":"SERVICE_UNAVAILABLE","message":"Object storage is unavailable"}
Enter fullscreen mode Exit fullscreen mode

Constructing the adapter directly inside the container gave the real error:

PermissionError: [Errno 13] Permission denied: '/app/home'
Enter fullscreen mode Exit fullscreen mode

/app/home is a strange path, given that I passed /home/appuser/soit-storage. The cause is this function:

@staticmethod
def _normalize_root_path(root_path: str) -> str:
    return root_path.replace("\\", "/").strip("/")
Enter fullscreen mode Exit fullscreen mode

strip("/") removes the leading slash too, so the absolute path /home/appuser/soit-storage becomes the relative path home/appuser/soit-storage, which fsspec's LocalFileSystem then resolves against the process working directory. The image sets WORKDIR /app/, so it lands in /app/home/....

And /app is not writable: server/Dockerfile does COPY ./ /app/ without --chown, leaving it owned by root, while the final instruction is USER appuser (uid 10001).

So inside the published image the local-filesystem path is effectively dead: whatever you pass ends up under /app, where a non-root process cannot create directories. Mounting a volume does not rescue it either — Docker creates the mount point owned by root as well. Making it work would mean running the API as root or pre-chowning a mount, and neither belongs in a guide aimed at people trying the project for the first time.

So MinIO stays. It is cheap, at least: one long-running container plus a minio-init that exits, on a couple hundred megabytes — an order of magnitude smaller than the Milvus group.

(For the record, MinIO wears two hats in the full topology: the platform's artifact store, and Milvus's object backend. It was never separable from Milvus anyway.)

3. The vector group leaves as a unit — and it is not a graceful degradation

milvus depends on etcd (metadata) and minio (data). Does the API still start without milvus and etcd? Yes, and the reason is in the adapter's constructor docstring (server/app/adapters/vector/milvus.py): the connection is established lazily on first use, so building the port during dependency injection does not fail when the vector store is unavailable.

But do not expect it to degrade into empty results, because the vector port has no environment-level fallback. From server/app/wiring/container.py:

def _create_vector_port(self) -> VectorPort:
    import os
    if os.getenv("PYTEST_CURRENT_TEST") or os.getenv("SOIT_TESTING") == "1":
        from app.adapters.vector.memory import InMemoryVectorPort
        return InMemoryVectorPort()
    from app.adapters.vector.milvus import MilvusVectorPort
    return MilvusVectorPort()
Enter fullscreen mode Exit fullscreen mode

The in-memory implementation is reserved for test runs; unlike the secrets port, it never consults ENVIRONMENT. The real effect: the platform boots, non-vector features work, readiness honestly reports vector: "unavailable", and knowledge retrieval raises the moment you use it.

The readiness response from the actual run says exactly that:

{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
Enter fullscreen mode Exit fullscreen mode

That conclusion held. But the same run surfaced something the code does not show you, which is the next section.

4. Wrong conclusion #2: without Milvus, the web container never starts

That readiness response took 34 seconds to come back.

The reason is not hard to guess: vector.check_ready() has to resolve the milvus hostname and open a connection, and the container is not there, so every request waits out DNS and connect timeouts. The vector probe is fail-soft, but it is not fail-fast — nothing bounds how long it may take.

Which runs straight into Compose's own health check:

{"Test": ["CMD-SHELL", "python -c \"...urlopen('http://localhost:9200/health/ready', timeout=3)\""],
 "Interval": "10s", "Timeout": "5s", "Retries": 5}
Enter fullscreen mode Exit fullscreen mode

The probe times out after 3 seconds, Compose gives it 5, and the endpoint needs 34. It cannot pass. In the run, the API container sat permanently at:

soit-api-1   Up 3 minutes (unhealthy)
Enter fullscreen mode Exit fullscreen mode

The service itself is fine — I logged into it. Only the health check fails. But web declares depends_on: api: condition: service_healthy, so a normal up -d web means web never starts at all. You get a stack with a perfectly working API and no UI, and very little to tell you why.

The fix is small: pass --no-deps for web as well. It is a static frontend; it only needs the browser to reach the API, not Compose's opinion about the API's health.

docker compose ... up -d --no-deps web
Enter fullscreen mode Exit fullscreen mode

Started that way, web comes up healthy and serves HTTP 200.

This is the one finding in this post that reading the code could never produce. On paper you get a guide that looks right and leaves you staring at a dead URL.

5. Vault genuinely does degrade

The secrets port is wired differently (same container.py):

if not settings.vault_url or not settings.vault_token:
    if self._allows_in_memory_adapters():
        from app.adapters.secrets.memory import InMemorySecretValueStore
        return InMemorySecretValueStore()
    raise RuntimeError("Production requires Vault URL and token for the secrets adapter")
Enter fullscreen mode Exit fullscreen mode

_allows_in_memory_adapters() accepts ENVIRONMENT values dev / development / local / test / testing, and compose defaults to development. So leaving VAULT_URL and VAULT_TOKEN empty swaps in the in-process secret store and the Vault container can stay down. Verified in the run: migrate, bootstrap and api all worked with no Vault anywhere.

The cost is in the name: in-process means not durable. The model API key you configure during the demo is gone the moment the container restarts.

6. Redis is three different questions

Redis is interesting because it is not a binary. The three places that use it disagree about what its absence means.

The event bus can be switched. memory is the default; compose is what changes it to redis:

backend = (settings.event_bus_backend or "memory").lower()
if backend == "redis":
    return RedisEventBus(...)
if backend == "memory" and self._allows_in_memory_adapters():
    return InMemoryEventBus()
Enter fullscreen mode Exit fullscreen mode

The same ENVIRONMENT guard applies — in production that branch raises. The in-memory bus only delivers within a single process, which is exactly why it pairs with folding background work into the API process.

The permission cache degrades gracefully. In server/app/kernel/identity/permissions.py the Redis accessor returns None when it cannot connect, and callers treat None as a cache miss and re-check against the database. One less cache layer, same answers.

Rate limiting is a hard dependency that usually never fires. RateLimiter (server/app/kernel/ports/common/rate_limiter.py) is a Redis sliding window with no in-memory equivalent. But the call sites are conditional (server/app/kernel/ports/tools/policy.py):

rate_limit = kwargs.get("rate_limit_per_minute") or self.rate_limit_per_minute
if rate_limit:
    await self.rate_limiter.check_rate_limit(...)
if self.daily_quota:
    await self.rate_limiter.check_rate_limit(...)
Enter fullscreen mode Exit fullscreen mode

No configured limit, no Redis call. Dropping Redis from a demo is therefore safe as long as you do not configure per-tool rate limits or daily quotas. That is the one item here that depends on what you do during the demo.

7. One service you fold in rather than remove

outbox-dispatcher runs the transactional outbox. Its setting says exactly what the flag means (server/app/settings/settings.py):

outbox_dispatcher_enabled: bool = False
"""Enable background outbox dispatcher in the API process."""
Enter fullscreen mode Exit fullscreen mode

The flag does not control whether dispatching happens — it controls where. Compose sets it to false and runs the same logic in a separate container. For a demo, invert it: set OUTBOX_DISPATCHER_ENABLED=true on the api service and skip the container. server/app/main.py reads the flag at startup and attaches the dispatcher to the API's lifespan.

Why production does the opposite: validate_runtime_requirements() contains if self.outbox_dispatcher_enabled: raise ValueError("Production requires the dedicated outbox dispatcher process"). Dispatching and request handling in one process compete for the same resources, and a restart interrupts both at once. Fine for a demo — and note that this is enforced by code, not advised by documentation.

8. Skip the ingestion worker unless you are demoing RAG

knowledge-ingest-worker builds from its own image target (server/Dockerfile) with one extra dependency group:

FROM base AS knowledge-worker
RUN --mount=type=cache,target=/root/.cache/uv \
    /bin/uv sync --frozen --no-dev --extra knowledge-worker
Enter fullscreen mode Exit fullscreen mode

That extra is docling[rapidocr] — document parsing and OCR. While we are here, a common misconception: torch, torchvision and torchaudio live in the local-embedding extra in pyproject.toml, not in knowledge-worker, and not in the API image either. The worker is lighter than people assume — but if your demo never uploads a document, it has no reason to exist.

9. The commands, as actually run

One Compose trap first: api lists milvus and vault in depends_on, so Compose starts them for you even when you leave them off the command line. Every step needs an explicit --no-deps, and you sequence the one-shot jobs yourself.

The env file (all of these override compose defaults):

printf '%s\n' \
  'ENVIRONMENT=development' \
  'VAULT_URL=' \
  'VAULT_TOKEN=' \
  'EVENT_BUS_BACKEND=memory' \
  'OUTBOX_DISPATCHER_ENABLED=true' \
  > .env.minimal
Enter fullscreen mode Exit fullscreen mode

Bring it up on the published images — overlay docker-compose.images.yml and pass --no-build, or Compose will build from source:

COMPOSE="docker compose --env-file .env.minimal -f docker/docker-compose.yml -f docker/docker-compose.images.yml"

$COMPOSE up -d --no-build postgres minio minio-init
$COMPOSE run --rm --no-deps migrate
$COMPOSE run --rm --no-deps bootstrap
$COMPOSE up -d --no-build --no-deps api web
Enter fullscreen mode Exit fullscreen mode

migrate prints a run of alembic upgrades; bootstrap prints Bootstrap completed. along with the admin ids.

Then verify. Remember that readiness takes more than 30 seconds (section 4), so give curl a generous timeout:

curl -s -m 60 http://localhost:9200/health/ready
Enter fullscreen mode Exit fullscreen mode

From the run:

{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
Enter fullscreen mode Exit fullscreen mode

vector: unavailable while the whole thing still reports ready is section 1's code path observed from the outside — the output is its own proof.

Then exercise a real path, not just the health endpoint:

curl -s -X POST http://localhost:9200/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"changeme123"}'
Enter fullscreen mode Exit fullscreen mode

An access_token in the response means the database and auth path are both working. The UI is on http://localhost:5000 with the same credentials.

docker ps will show the API as unhealthy, and that is expected (section 4). The service is fine.

What this is not

As usual, the limits:

  • This is not a supported deployment shape. It is a demo trim. Set ENVIRONMENT=production and every shortcut above is closed off one by one: validate_runtime_requirements() demands the Redis event bus, the dedicated outbox process, Vault, OpenTelemetry, and plugin signature and digest verification. Missing any of them fails startup. That is deliberate fail-closed behaviour.
  • The API stays unhealthy, so do not hand this topology to anything that orchestrates on container health — Kubernetes probes, or start-up ordering that waits on a healthcheck, will both break.
  • In-memory means gone on restart — secrets, and any event in flight on the in-memory bus.
  • Without Milvus, vector features raise rather than return empty. Demoing knowledge bases means putting milvus and etcd back.
  • The rate-limit caveat is yours to judge: dropping Redis assumes no configured limits.
  • The default SECRET_KEY is change-me, and bootstrap will warn you about it (InsecureKeyLengthWarning). Harmless for a demo; do not let that value outlive one.

Two bugs found along the way

Writing this turned up two problems of our own. Both are filed, and it seems fair to say so here rather than quietly fix them:

  1. _normalize_root_path() calls strip("/"), which turns absolute paths into relative ones, making the local filesystem storage backend unusable inside a container (section 2). That function is presumably meant to normalise object-storage key prefixes; backends like file://, where an absolute path means something, should not get the same treatment. (issue #43)
  2. The vector readiness probe has no timeout, so a missing vector store drags /health/ready past 30 seconds and makes the Compose health check fail permanently (section 4). Fail-soft was implemented; fail-fast was not. A seconds-level timeout on check_ready() would give you both. (issue #44)

Worth noting: both are things you only hit by actually running a reduced topology, and our own CI runs the full one. Which is probably an argument for supporting the minimal shape officially.

Why write this down at all

If four containers are enough, why does the default ask for twelve?

Because the default topology targets the production shape, not the demo shape. Every service cut above maps to a requirement that production enforces in code: secrets need a real secret manager, events need to cross process boundaries, dispatching needs to scale independently, vectors need to persist. You get something you can experiment against as if it were production, and the price is a first command that looks frightening.

The point is that the distance between those two shapes is measurable in a handful of environment variables — and measuring it happens to be the fastest way to understand the architecture: the health check tells you the hard dependencies, the wiring code tells you which ports have fallbacks, and validate_runtime_requirements() tells you where production draws its line.

But keep the other lesson too: reading the code gives you hypotheses; running it gives you conclusions. Two of my three were wrong, and the wrong two were exactly the ones that would have stopped you.

Try it

SOIT is Apache-2.0 and the code is on GitHub:

  • Repository: github.com/soit-ai/soit
  • Full quickstart (the twelve-service path): docs/quickstart.md in the repo
  • Governance demo: docs/governance-demo.md — a 20-minute local run that walks through permissions, secrets, call auditing, cost attribution, replay and regression

If you get the minimal topology running, or get stuck on a step, open an issue and say so. Right now this trim only exists as a blog post; if the feedback says it is useful, we will turn it into a Compose profile so --profile minimal does the whole thing — and fix those two bugs on the way.


Disclosure: I maintain SOIT.

Top comments (0)