Part 4 of the series: Building a Self-Hosted AI Development Platform
5 min read

Building a Self-Hosted AI Development Platform — Part 4: Docker LXC, Portainer, and the First Dashboard

Part 4 of the Forge series: choosing Docker-inside-LXC over a VM, the shared network decision that shaped everything after, and the day the root disk hit 97%

Part 4: Docker LXC, Portainer, and the First Dashboard

Part 3 left the platform with storage it could trust: a ZFS mirror for the data that matters and SSD thin pools for the disks that move. This post climbs one layer up and builds the application layer — the single Docker host that everything in this series ultimately runs on, the tool that manages it, and the first dashboard that made Forge feel like a platform rather than an SSH session.

It also covers the day, months later, when that Docker host’s root disk hit 97% and the platform politely reminded me that thin provisioning is a promise, not a plan.

Choice One: Where Does Docker Live?

Every service on the roadmap — Git hosting, dashboards, automation, media — was going to be a Docker container. So the first real architectural question of the application layer was: what runs Docker?

On a Proxmox host there are three candidate answers, and the differences matter on a 32GB machine.

Docker on the Proxmox host itself is the option every forum warns you about, correctly. The hypervisor is the one layer that must stay boring. Mixing container runtimes, iptables rules, and package upgrades into the host that owns your storage and backups turns every Docker experiment into a bet staked on the whole platform. Ruled out immediately.

Docker in a full VM is the orthodox answer, and the safe one: a real kernel boundary, total isolation, no surprises. The cost is overhead — a VM reserves its slice of RAM up front and runs its own kernel, and on a machine that would eventually juggle two dozen containers plus a dedicated web development VM, spending gigabytes on an extra kernel for no functional gain felt wasteful.

Docker inside an LXC container is the middle path I took. An LXC shares the host kernel, so overhead is near zero — the container’s RAM is whatever its processes actually use. Proxmox treats it like a first-class citizen: snapshots, scheduled backups, resource limits, all from the same UI. The trade-offs are real but known: the container needs the nesting feature enabled for Docker to work at all, and running it unprivileged — the sensible security default — brings the UID-shift tax that Part 3 already paid an evening for.

So Docker LXC 100 was born: Debian, 60GB root disk on ssd-fast, nesting enabled, unprivileged, with /forge-hdd mounted in at /mnt/forge-hdd. To this day it is the single most important guest on the machine — and the Proxmox backup job from Part 2 covers it nightly, which is what makes every risky change since feel routine.

Choice Two: One Shared Network, Routed by Name

Before deploying a single service, one command set up the decision that shaped everything after:

docker network create platform

Every service stack on Forge joins this one shared external network. That’s the whole trick — and it’s the line my build notes call out as the moment the platform became easy to manage.

Because everything shares a network, every container can reach every other container by name. The dashboard talks to uptime-kuma:3001. The reverse proxy — when it arrives in a later post — routes music.example.com to navidrome with two lines of config, no IP addresses, no published ports, no port-collision spreadsheet. Adding a service to the platform means adding it to the network and telling Caddy its name. That’s it.

The alternative — the default Docker behaviour of one auto-created network per Compose stack — means every cross-stack connection needs explicit wiring, and a reverse proxy needs to straddle every network it routes to. With one stack, fine. With the twenty-plus this platform grew into, it’s the difference between a platform and a pile of containers.

Portainer: The Honest Compromise

Stack management went to Portainer, and I want to be honest about what that choice is, because it sits in tension with this series’ core principle.

Portainer is a web UI for Docker: deploy a Compose stack by pasting it in, watch logs, restart containers, see at a glance what’s running and what’s unhealthy. For a platform managed from a laptop (and occasionally, ill-advisedly, from a phone), that visibility is genuinely valuable — docker ps over SSH doesn’t tell you the shape of a twenty-service platform the way a stacks list does.

The tension: Part 1 declared that Git-tracked Compose files are the source of truth, and Portainer makes it deliciously easy to betray that. Clicking “deploy” with a pasted, tweaked Compose file is faster than committing first — and every time I did it, the live platform drifted a little further from the repository. My build notes eventually grew a dedicated gaps file just to track what was live but uncaptured, and reconciling Portainer’s stacks against the repo became a recurring chore. The full story of digging out of that hole is Part 9’s; the summary is that Portainer is a deployment convenience, and the moment you treat it as the source of truth, your platform becomes unexplainable.

Portainer also joined the list of services that would never get a public hostname. It can delete every container on the box — it’s a control-plane interface, and control planes stay private. That rule comes back in Part 8.

The First Dashboard

With the host and the network in place, the first two services set the platform’s tone.

Uptime Kuma came first — monitoring before there was much to monitor, which sounds backwards but was deliberate: every service added after it got a health check on day one, not as an afterthought.

Homepage came second, and mattered more than a dashboard should. Homepage is a tile-based start page: services grouped into sections, each tile linking through and (for services with APIs) showing live status. Replacing its demo config with the first production dashboard — an Infrastructure section listing Proxmox, Portainer, Homepage itself, and Uptime Kuma — was maybe an hour of YAML. But it changed what the machine was. Before it, Forge was a hypervisor you had to know your way around; after it, there was one URL where the platform presented itself. Every service in the rest of this series announced its arrival as a new tile.

What Broke: The 97% Day

The 60GB root disk seemed generous. Months later — 25 July, with the platform fully grown — it hit 97% full, and thin-provisioned generosity revealed its edges.

Two things had been quietly eating the disk. The first is Docker’s oldest gotcha: image accumulation. Every stack update pulls new image layers; the old ones stay behind, invisible unless you go looking. Twenty services updating for months left over sixteen gigabytes of orphaned layers. A single unused-image cleanup reclaimed 16.09GB — a quarter of the disk — for zero functional cost.

The second was Part 3’s recorded deviation coming home: MinIO’s object data, about 15GB of it, still sitting on the SSD tier at /opt/platform/data/minio when the plan had always said object storage belongs on the big mirror. The incident forced the migration that should have happened earlier: MinIO’s data moved to /mnt/forge-hdd/appdata/minio, was verified there, and only then was the old copy removed. The root disk landed at 43% with 33GB free — and the deviation Part 3 confessed to is now resolved, the way documented deviations eventually are and undocumented ones never quite are.

Two lessons went into the build notes. Image pruning is maintenance, not an emergency response — it now happens routinely rather than at 97%. And a metric nobody looks at is not monitoring: the observability stack in the next post already existed when this happened, and the disk still had to fill up before anyone noticed. More on that lesson in Part 5.

What This Stage Delivered

One LXC, one shared network, one management UI, one dashboard, one monitor. Barely five containers — and yet this is the layer every later post stands on. The pattern was set: a new service is a Compose file on the platform network, a Caddy name, a Homepage tile, and an Uptime Kuma check. Everything from the Git server to the photo library followed that identical path.

Next in the Series

The dashboard shows what exists; it doesn’t show what’s wrong. Part 5 makes the platform properly observable — Uptime Kuma monitors for every service, and the Grafana, Prometheus, Node Exporter, and cAdvisor stack that finally let Forge answer questions like “what is eating the disk?” before the disk answers them for you.

Next: Part 5 — Observability with Uptime Kuma, Grafana, and Prometheus