Question: can golden-eye, the NAS and the VPS organise projects and services the same way?
Answer: yes — but as a grammar, not one absolute path. Each host keeps its own root; the shape
underneath it is identical, and the group names come from the estate taxonomy already published at
estate.nsystems.live. The alternative (one real directory path on all three) would force bind mounts and
backup jobs across hosts to disagree, and would gain nothing a shared grammar doesn't already give.
The grammar
<root>/
├── platform/ per-host infrastructure: tunnels, ingress, monitoring, ddns, backups
├── stacks/<group>/<name>/ runnable docker stacks — group is one of the seven estate groups
├── apps/ application source that is not itself a stack (builds, SDKs, specs)
└── data/ bind-mount payload (volumes live in docker's own store)
| Host | Root |
|---|---|
| golden-eye | ~/agent-lab/estate/ |
| NAS | /volume1/docker/ |
| VPS | /srv/estate/ |
The seven groups: client-wp, client-static, infra, business, media, product, games.
They are the same words used in the estate report and on every placement decision, so "where does this
belong" has one answer everywhere.
Naming rules
- One directory per stack, named for the service — not
deploy, notdocker, notstack. - Compose project name =
<group>-<service>(-pon the command line, orname:in the compose file).docker psthen reads like the report:business-invoiceninja,media-media-platform. - Container names come from compose, never hand-assigned — so a
down/upcycle reproduces them. - Public hostnames never encode the group. URLs are a product decision (
files.nsystems.live), the layout is an operations decision. Never rename a hostname to satisfy the tree. - Group overrides where the taxonomy is finer than the software:
cloudflared,traefik,ddns,tailnet-dns,portainer,filebrowserareinfraeven when a classifier would file them under business;vault-tlsbelongs withbusiness/vaultwarden, not with games.
The reference implementation already exists: the NAS
/volume1/docker/
├── platform/{cloudflared,ddns,_secrets,authelia}
├── stacks/media/media-platform/ <- 21 containers, one stack, one directory
├── stacks/{vidalstudios,nsystems-monitoring,backup}
└── data/{media,media-library,torrents,downloads,appdata,db}
That is the shape to copy. It is why the media stack survived the *arr rebuild intact — one directory holds the compose file and every service's config.
Where the three environments actually stand
Measured 2026-11-12 (layout-audit.py, 48 compose stacks, 7 ad-hoc containers).
Three golden-eye game stacks were adopted during the first Phase 2 pass (below), so the table
shows 5 conforming stacks rather than the pre-migration 2.
| Host | Stacks | Conforming | Drift |
|---|---|---|---|
| NAS | 17 | 2 (media/deemix, media/media-platform) |
Names aren't <group>-<service>, and platform/* services are mixed into stacks/ |
| VPS | 6 | 0 | /opt/{urpost-core,seed,dustline,...} — no group at all |
| golden-eye | 25 | 9 | Six roots: ~/deploy/, ~/agent-lab/workspaces/, 01-products/{web,plugins,games}/, ~/ itself, plus compose dirs that no longer exist |
golden-eye is the drift, and it is the right host to be drifted: 14 of its 25 stacks are T3 games and experiments. The two hosts that carry revenue — the NAS — are already closest to the standard.
Migration plan, ordered by risk rather than by tidiness
Phase 0 — adopt the grammar now (zero risk).
New work lands in <root>/stacks/<group>/<service>/ with -p <group>-<service>. The audit script runs
before any new stack is added, so drift cannot grow while the old stacks are still in place.
Phase 1 — platform directories (low risk).
Move per-host infrastructure into <root>/platform/: NAS platform/{cloudflared,ddns} already there,
add monitoring; VPS /etc/cloudflared → keep (systemd expects it) but move /opt/infra → platform/;
golden-eye ~/.cloudflared → keep (the connector mounts it) and record the exception in this file.
A platform move that touches a systemd unit path is not worth doing for symmetry alone.
Phase 2 — T3 games and experiments on golden-eye (medium risk, no client impact).
Move one stack at a time: docker compose down, git mv the directory, docker compose -p games-<name>
up -d, verify the hostname, then delete the old directory. Nothing a client sees depends on these, and a
mistake costs a restart. Expect ~20 stacks; do not batch them into one afternoon.
Phase 3 — stopped and ad-hoc containers (housekeeping).
Seven containers run outside compose (/docker run): they cannot be reproduced from source, which is the
real defect. Either write the compose file that recreates them or retire them.
Never — renaming the T0 stack for cosmetics. stacks/vidalstudios on the NAS keeps its name and its
project name until the next planned maintenance window, because a rename means a down/up on the live
site and buys nothing a reader of this document can't infer.
Checking conformance
cd ~/agent-lab/01-products/ops/inventory
python3 layout-audit.py # prints every stack, its group, its canonical path, conforms yes/no
Output is a table of truth, not an opinion: conforms compares the compose project's recorded
working_dir against <root>/stacks/<group>/. Full output for today: layout-audit.txt.
Phase 2 in practice — the source is gone, so capture the container instead
The first pass moved games-tinytrack-server, games-shatterfront and games-tiny-swords-game into
estate/stacks/games/. It could not be done by moving directories, because four of the five game source
directories no longer exist:
| Stack | Recorded working dir | Exists? |
|---|---|---|
| tinytrack-server | ~/tinytrack-server |
no |
| shatterfront | ~/agent-lab/workspaces/shatterfront/docker |
no |
| secrets-of-crimson-haven | ~/agent-lab/workspaces/secrets-of-crimson-haven/docker |
no |
| ten-banners / mapforge | ~/agent-lab/workspaces/five-banners |
no |
| mazeforge | ~/agent-lab/workspaces/mazeforge |
no |
| scrap / scrap-api | ~/agent-lab/workspaces/scrap/docker |
yes |
Those containers were still serving 200 while nothing on disk could rebuild them. So the migration became
capture-as-code: read docker inspect, write a docker-compose.yml that reproduces the container
(image, ports, volumes, restart policy), save the image itself (docker save | gzip, 20–58 MB each), then
recreate the stack under its canonical project name. Verified by hostname afterwards —
tinytrack.nsystems.live, shatterfront.nsystems.live, spritemap.nsystems.live all 200 with their
original titles.
Two lessons worth keeping
- Docker ran out of subnets during the first swap.
failed to create network …: all predefined address pools have been fully subnetted— 48 networks had accumulated, one per stack ever run, and the container came down before the replacement could start.docker network prune -ffreed 30 of them (48 → 18). Single-container stacks now usenetwork_mode: bridgeso they never allocate a network, and network pruning belongs in routine maintenance, not in an emergency. - Never remove the old container before the new one has a network. The safe order is
docker network ls | wc -l→ prune if high → create → verify → remove. A T3 stack lost two minutes of uptime to this; the same mistake on a T0 stack would have been an outage.
Docker networking: pools big enough that this never bites again
Golden-eye ran out of container subnets on 2026-11-12 — failed to create network …: all predefined address
pools have been fully subnetted — with 48 networks accumulated from every stack ever run. The stock daemon
hands out roughly sixteen /16s and then starts taking ranges out of 192.168.0.0/16, which is a hazard of
its own: one day it would claim 192.168.40.0/24 and the laptop would lose its route to the NAS.
/etc/docker/daemon.json on golden-eye now reads:
{
"live-restore": true,
"default-address-pools": [
{ "base": "10.200.0.0/13", "size": 24 },
{ "base": "10.216.0.0/13", "size": 24 }
]
}
- 4,096 /24 networks instead of ~16, all inside
10.200.0.0/13–10.216.0.0/13. - Deliberately clear of
192.168.40.0/24(the LAN),100.64.0.0/10(Tailscale), and the ranges Docker had already handed out (10.213.x,10.231.x). live-restore: truemeans a futuresystemctl restart dockerleaves containers running — verified by restarting the daemon and watching container uptimes stay put.
Existing networks keep their old subnets; the new pools govern everything created from now on.
Rules that keep the pool from filling up again
- Single-container stacks use
network_mode: bridge— no network is allocated at all. The three adopted game stacks do this. - Multi-service stacks share one named network (
estate) instead of each project minting its own. docker network prune -fbelongs in maintenance, next to image pruning — each dead stack leaves a network behind, and they are invisible until creation fails.
NAS (Docker 24.0.2, DSM-managed) and the VPS (9 networks) still run stock pools. Neither is close to the limit; the NAS config is DSM-owned, so it is deliberately left alone rather than hand-edited under a T0 host.
Phase 2 complete for the games group (9 stacks, 21 containers)
All nine golden-eye game stacks now live in estate/stacks/games/<service>/ as captured compose projects
and run under games-<service> project names: tinytrack-server, shatterfront, tiny-swords-game,
secrets-of-crimson-haven, space-formations, fpswg-map, ten-banners (+mapforge), scrap (+scrap-api),
mazeforge (+server). Every image is saved beside its compose file (image.tar.gz), because for five of
them the image is the only surviving copy of the software. Every published hostname was re-checked
afterwards: ten hostnames, all 200.
Two failures worth remembering, both from capture rather than from the code:
- Service names are DNS.
mazeforge's nginx proxies tohttp://server:3000. Capturing it with a service calledmazeforge-serverproducedhost not found in upstream "server"and a restart loop — the container was fine, the network name was not. Capture must preserve the original compose service names, not the container names people see indocker ps. container_nameoutlives the service. After renaming the service,up -drefused to create the new container because the old one held the name;docker compose down --remove-orphansthenup -dresolved it. Renaming a compose service is a down/up, never anup.
Adoption stopped being a directory move after the second stack: the source was gone for most of them, so each one is now defined by exactly two artefacts — a compose file and a saved image.