featured image

From Legacy Silicon to AI Brain: Orchestrating Complex Workloads without the Overhead

This tutorial provides a step-by-step guide to deploying Milvus, a high-performance vector database, on a repurposed nine-year-old gaming PC running TrueNAS Scale. It covers the architectural decisions behind using Portainer for container orchestration, the translation of Docker Compose files for persistent ZFS storage, and the critical resource management techniques necessary to run enterprise-grade AI workloads on legacy hardware.

Published

Wed Nov 12 2025

Technologies Used

Milvus TrueNAS Scale ZFS Docker
Beginner 15 minutes

Deploying lightweight apps on TrueNAS Scale is easy. Nextcloud, Uptime Kuma, Vaultwarden — they all go up without much friction. The real test is when you introduce a distributed system with multiple interdependent containers that need persistent storage, memory limits, and correct startup ordering. That’s where most guides stop, and that’s exactly where this one starts.

I’m going to walk through deploying Milvus — a vector database built for AI workflows like semantic search and RAG — on a nine-year-old gaming PC running TrueNAS Scale, using Portainer’s Stacks (Docker Compose). The goal isn’t just “get it running.” It’s understanding why the official docs will break your setup if you follow them verbatim, and how to fix it.

What You Need Going In

Knowledge:

  • The difference between Docker named volumes and host bind mounts
  • Basic YAML and docker-compose syntax
  • How TrueNAS handles ZFS dataset permissions (ACLs)

Environment:

  • Host OS: TrueNAS Scale (Debian-based)
  • Orchestrator: Portainer Community Edition deployed via TrueNAS Apps
  • Storage: A configured ZFS pool (e.g., tank)
  • Target: Milvus Standalone v2.6.x

Before anything else, create a dedicated dataset in TrueNAS for this stack. I’ll assume you’ve created one at /mnt/tank/apps/milvus.

Why Portainer Instead of the Built-In App Catalog

TrueNAS Scale has a perfectly fine app catalog for simple deployments. Milvus isn’t simple — it needs MinIO for object storage, etcd for metadata, and precise control over how each container mounts storage. The built-in catalog abstracts away too much. Portainer gives you direct access to the Docker Compose layer, which means you control every bind mount, every memory limit, every network.

The architecture I’m running looks like this:

graph TD
    A[TrueNAS Scale Host] -->|ZFS File System| B[/mnt/tank/apps/milvus]
    B --> C[etcd_data/]
    B --> D[minio_data/]
    B --> E[milvus_data/]
    
    F(Portainer CE) -.->|Orchestrates| G{Docker Engine}
    
    G -->|Network: milvus-bridge| H[Container: milvus-etcd]
    G -->|Network: milvus-bridge| I[Container: milvus-minio]
    G -->|Network: milvus-bridge| J[Container: milvus-standalone]
    
    H ==>|Bind Mount| C
    I ==>|Bind Mount| D
    J ==>|Bind Mount| E

The key idea here: compute is ephemeral, data is not. You can tear down every container and rebuild the stack without losing a single vector, because the data lives on ZFS — not inside Docker.

Why the Official Docker Compose File Will Break Your Setup

The official Milvus standalone docs give you this:

# DO NOT USE THIS IN PORTAINER ON TRUENAS
version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd

That . in DOCKER_VOLUME_DIRECTORY resolves to Portainer’s internal working directory — somewhere deep in /var/lib/docker/volumes/.... You’ll have no snapshot protection, no SMB access, and no straightforward way to back it up. It’s data stored in a place you’ll forget about until it disappears.

The fix is replacing every relative path with an absolute host bind mount pointing at your ZFS dataset.

The Stack: Supporting Infrastructure First

Open Portainer, go to Stacks, click Add Stack, name it milvus, and paste the following into the web editor.

Start with the network and the two dependency containers:

version: '3.5'

networks:
  milvus-net: 
    driver: bridge

services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - /mnt/tank/apps/milvus/etcd:/etcd
    networks:
      - milvus-net

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - /mnt/tank/apps/milvus/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    networks:
      - milvus-net

Note that I changed MINIO_ACCESS_KEY from the default. Change yours too in any real deployment.

The Stack: The Milvus Engine With Memory Limits

Now append the main standalone service. This is where the actual vector indexing happens, and where you need to be careful on older hardware:

  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.6.13
    command: ["milvus", "run", "standalone"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
    depends_on:
      - "etcd"
      - "minio"
    ports:
      - "19530:19530"
      - "9091:9091"
    volumes:
      - /mnt/tank/apps/milvus/data:/var/lib/milvus
    networks:
      - milvus-net
    deploy:
      resources:
        limits:
          memory: 8G

That memory: 8G limit is not optional on a machine with 16–32 GB of RAM. Here’s why: Milvus builds HNSW (Hierarchical Navigable Small World) graphs in heap memory to make similarity search fast. At the same time, TrueNAS’s ZFS ARC eats unused RAM to cache frequently accessed disk blocks. Without the cgroup limit, Milvus can balloon its heap until the Linux OOM killer steps in — and at that point it’s either the Milvus container or your TrueNAS host that pays the price. The limit keeps them from fighting.

Once you hit Deploy the stack, Portainer sends the Compose YAML to the Docker daemon, which pulls images, creates the milvus-net bridge, and starts containers in the dependency order you specified.

Three Things That Will Bite You

ACL mismatches. Docker containers run as root by default. When Milvus writes index files to /mnt/tank/apps/milvus/data, those files are owned by root. If you later try to access the dataset over SMB, you’ll get permission denied. Before deploying, set the ZFS dataset to the “Generic” or “SMB” preset in TrueNAS and grant your personal account read/write access via the ACL manager. Portainer writes as root and won’t care, but you’ll be able to back up and migrate the files normally.

Tailscale startup races. If your server reboots after a power cut, Docker might spin up the Milvus stack before Tailscale has brought up its mesh tunnel. The fix: bind container ports to 0.0.0.0 (the format "19530:19530", not "100.x.x.x:19530"). The server is already behind your router’s firewall, so listening on all interfaces is safe — and it means Milvus is available on the Tailscale IP as soon as Tailscale itself comes up.

Hard stops corrupt databases. Milvus and etcd are stateful. If you use Portainer’s “Kill” button instead of “Stop,” you’re sending SIGKILL — the container dies immediately without flushing its write buffer to disk. etcd in particular needs to write its final state before exiting. Always use the graceful Stop action. It sends SIGTERM, waits for a clean shutdown, and your data stays intact.

With the stack running and these pitfalls avoided, you’ve got the backend infrastructure to run LLM-powered applications, semantic search, or local document RAG — all on hardware that cost nothing, using data that stays entirely on your own disks.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!