06Phases 5–6ready

Install Docker, deploy and update services, protect their data, and test them as a user.

06 — Run household services safely

Chapter TL;DR: Run one Docker Compose project at a time. The LLM prepares and checks each release without root or Docker access; the human runs one tested script until a reviewed fixed action exists.

This chapter deploys household applications on one Linux server. Plex is the first example. Home Assistant uses its supported appliance, and optional media automation comes later with its own limits.

Before installing Docker#

TL;DR: Do not add containers until access, storage, identity, backup, firewall, power, and agent boundaries are proven.

Do not install Docker merely because the server boots. The following facts must already be true:

  • The owner has tested local Tier 0 access to the agent Mac and server without internet, cloud identity, or ordinary applications.
  • Secure remote entry works through the chosen VPN or overlay path, and the recovery path does not depend on the application stack being healthy.
  • The server has a stable name and address; time, DNS, updates, SSH, the host firewall, and storage mounts work after reboot.
  • Human, service, and agent identities exist. Numeric UID/GID assignments have been recorded.
  • Persistent application state, bulk media, temporary working space, and backup destinations are distinct data classes.
  • At least one independent backup and one restore test already exist for a small representative data set.
  • The owner has classified the first service by criticality, users, exposure, acceptable downtime, and acceptable data loss.

If any item is false, return to the chapter that establishes it. Compose cannot repair a missing storage design, remote-access path, identity model, or backup.

Entry tests#

TL;DR: Record current host, mount, network, backup, and privilege-denial evidence before Docker changes the system.

The agent performs these read-only checks and records the output with host identity and timestamp:

  1. Resolve the server by its durable internal name from the agent Mac and one household client.
  2. Connect using the dedicated agent identity and confirm that the target host, OS, time, and expected network are correct.
  3. Confirm every intended persistent mount with findmnt; verify its filesystem and backing device or remote source. Merely finding a directory is not sufficient.
  4. Confirm the free-space floor for application state, media, databases, image storage, and temporary transcodes.
  5. Restore the representative backup into a disposable location and compare it with the source.
  6. Confirm that a household client cannot reach SSH, Docker, the hypervisor, backup administration, or other management surfaces.

The owner decides whether the test evidence is good enough to begin. The agent does not “fix forward” around a failed prerequisite.

Make these decisions first#

TL;DR: Set runtime, authority, exposure, state, identity, secrets, update, and recovery policy before selecting applications.

Resolve these decisions before choosing applications or copying Compose files from the internet.

DecisionRecommended defaultAlternativeReopen the decision when
Initial platformOne Linux host running Docker Compose v2Rootless Docker for a disposable, device-free lab stackA workload cannot be safely bounded under the default, or rootless limitations have been tested and accepted
Unit of deploymentOne Compose project per application or tightly coupled application/database pairA larger suite project for components that truly share lifecycle and dataCross-project dependencies cause repeated operational failures
Docker authorityRootful daemon; no ordinary human or agent account in the docker group; privileged Compose commands are human-executed initiallyA separate rootless runtime for disposable, device-free, low-risk servicesA reviewed fixed action has become worth building, or rootless limits have been measured
Persistent stateExplicit, pre-created host paths under a recorded state rootNamed volumes when the application or supported image requires themBackup/restore tooling can prove the named volume is fully covered
Bulk mediaA separate data tree, mounted read-only into consumersRead/write only for the exact importer that must organize a libraryA new workflow requires a documented writer
Temporary dataA bounded scratch path or tmpfs; safe to erasePersistent cache only when rebuilding it is unacceptably expensiveCapacity or wear measurements justify the change
IngressOne host-level Caddy service owns ports 80/443; application backends bind to loopbackDirect LAN port for protocols or discovery that do not tolerate a reverse proxyA measured protocol requirement justifies an exception
AuthenticationApplication-local named accounts first; native OIDC later; proxy authentication only for browser-only apps without OIDCKeep local accounts permanently for a very small deploymentAccount lifecycle, MFA, or repeated sign-in burden justifies an identity provider
UpdatesProposed, reviewed, pinned, backed up, deployed, verified, and rollback-testedAutomatic patching only for a separately bounded low-value service with tested recoveryUnpatched exposure becomes riskier than controlled update toil
OrchestrationStay on ComposeMove selected workloads to k3s/KubernetesMulti-host scheduling, dependency recovery, service discovery, rollout needs, or Compose toil—not fashion—justify it

The ordering is intentional. Runtime authority and data paths determine the blast radius of every service that follows. Ingress determines what can be reached. Authentication determines who can use what is reachable. Product selection comes after those boundaries, not before them.

What Docker Compose does#

TL;DR: Compose packages and coordinates processes; it does not provide a VM-grade security boundary or automatic recovery design.

A container packages a process and its runtime dependencies. It is not a small virtual machine and it is not a security boundary strong enough to justify giving an untrusted process the host.

Compose declares a group of containers, their networks, mounts, secrets, health checks, and lifecycle. On a single host it is a good fit: readable, widely supported, and easy to reconstruct. Docker itself documents single-server Compose as a production pattern for appropriate workloads. Docker: Use Compose in production

Compose does not provide:

  • another machine to run on when this host fails;
  • application-aware database recovery;
  • automatic restart merely because a health check reports unhealthy;
  • a guarantee that an application reconnects after a dependency is replaced;
  • safe permissions simply because a process is inside a container;
  • backups, immutable history, or a tested restoration path;
  • wise update ordering.

Compose starts dependencies in order, but a container being running does not mean its service is ready. depends_on with condition: service_healthy can wait for a dependency health check, and restart: true can restart a dependent after an explicit Compose update of that dependency. The application must still retry lost connections at runtime. Docker: Control startup and shutdown order

That distinction prevents a common failure pattern: an NFS mount, database, or DNS dependency is late; the application fails; an agent responds by rewriting permissions or adding watchdogs. Test ordinary dependency timing and readiness before redesigning anything.

Install Docker as one bounded host change#

TL;DR: Install the official stable engine once through a reviewed human packet, then prove reboot, firewall, logging, and privilege behavior.

Work profile: operational size large; quota large; human effort medium; agent effort large; wait medium; outage planned host/container outage; server firewall/runtime changes; lower tiers retained; clock duration unknown until target-specific evidence exists.

Install Docker only now—not during the base Linux build. Keeping the earlier host Docker-free made it possible to verify SSH, mounts, firewall policy, reboot behavior, and the agent's lack of privilege before Docker changed the network and authority model.

The default is Docker Engine from Docker's official Ubuntu apt repository plus the Compose plugin. As of September 2026, Docker lists Ubuntu 26.04, 24.04, and 22.04 LTS as supported. Use the current official page for the installed release; do not substitute Docker Desktop, a Snap, the legacy docker-compose binary, or the get.docker.com convenience script on this server. Docker describes that script as intended for testing/development and warns that it installs the latest stable release with limited customization. Docker: Install Engine on Ubuntu and Docker: Install the Compose plugin

Give the coding agent this work order from its unprivileged server account:

Inspect the exact Ubuntu release and architecture, current APT sources and package pins, installed container runtimes, firewall backend, Docker-related groups, and whether /var/lib/docker already contains state. Read Docker's current official Ubuntu installation and firewall documentation. Do not install or remove anything. If an existing runtime or data exists, stop and report it. Otherwise prepare one human-run packet that adds Docker's signed stable APT repository, installs docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, and docker-compose-plugin, configures the bounded local logging driver, enables Docker at boot, and changes no group membership. Include exact prechecks, postchecks, rollback, and the current package versions that APT will select. Do not use a downloaded shell installer.

The human reads the script, opens one administrator SSH session, and runs one exact command. The script contains the repository key and package/version closure; checks its own language and input fixtures; simulates and validates the APT transaction; installs Docker; checks the daemon, socket, listener, logging, and agent denial; and installs a one-use post-reboot verifier. It reports PENDING REBOOT before restart and records SUCCESS only when the verifier proves the expected state afterward. The agent receives no sudo, root terminal, or Docker socket. A conflict or existing Docker data stops the script unless removal was separately approved.

The script must prove all of these before deploying any application:

  1. sudo systemctl is-enabled docker and sudo systemctl is-active docker both report the intended state.
  2. sudo docker version, sudo docker compose version, and sudo docker info identify the expected engine, Compose plugin, storage driver, firewall backend, cgroup mode, data root, and local logging driver.
  3. The ordinary human, observer agent, and deploy-preparation agent are not members of docker; their unauthenticated docker ps fails.
  4. No unexpected listener appears on the LAN. Docker's own test image may run once under human privilege and is then removed.
  5. After a reboot, Docker returns, the agent still cannot reach its socket, the host's Tier 0/1 tests still pass, and both IPv4 and IPv6 firewall observations match the recorded baseline.

Do not treat the existing UFW rules as proof that future published container ports are filtered. Docker warns that published ports can bypass expected UFW processing; container ingress and egress are tested separately below.

Decide how the agent can deploy before adding services#

TL;DR: Define and denial-test how agents prepare deployments before any valuable service or data reaches Docker.

Treat Docker access as administrator access#

TL;DR: Keep agents, ordinary users, CI, dashboards, and containers away from the Docker socket and docker group.

Do not add the agent, the everyday administrator, a CI runner, Portainer, an identity provider, or a convenience dashboard to the docker group. Docker states plainly that this group grants root-level privileges. A process controlling the daemon can mount the host filesystem, start privileged containers, and replace host-visible data. Docker: Linux post-installation

Also do not mount /var/run/docker.sock into a container merely to obtain discovery or automatic updates. The fact that a mount is read-only does not turn the Docker API into a harmless inventory feed.

The default design keeps the ordinary rootful daemon because Plex hardware devices, occasional host-network workloads, and the broad Compose ecosystem are easier to support. The daemon is controlled only by root. Initially, the agent never reaches it: it prepares a complete deployment packet and the human invokes the exact privileged command.

Rootless Docker is a valid alternative for a separate low-risk runtime. It runs both daemon and containers inside a user namespace without root privileges, but requires subordinate UID/GID ranges and has operational caveats around ports, cgroups, and devices. Use it when its limits are an advantage rather than adding it reflexively. Docker: Rootless mode

On day one: prepare, check, run once, and verify#

TL;DR: Let the agent render and review releases, but keep privileged application behind one sealed human execution boundary.

This guide does not ship a secure general Docker action broker. A bespoke root daemon proposed by the same agent it is meant to constrain is not safe merely because a few rejection tests pass. Keep this authority ladder honest:

  1. Unprivileged preparation. The agent writes desired state under its workspace, resolves the complete Compose model with docker compose config, compares it with the rejection checklist, and produces a semantic diff. A credential-free second reader checks the same rendered model when the change adds authority. This is review, not a privileged enforcement service.
  2. One human execution boundary. The agent presents all privileged steps for that release at once: exact target and digest, mount assertions, backup/recovery point, the reviewed release hash, a root-owned sealing step, explicit Compose file/project/environment arguments, external tests, rollback command, and stop conditions. The human invokes the packet once with administrator privilege. Privileged Compose never rereads an agent-writable directory.
  3. One fixed action at a time. Only after repeated deployments create real toil, a separately reviewed root-owned helper may expose one exact operation for one fixed project and release format.
  4. General action service. Adopt only if a real implementation has a threat model, independent security review, caller authentication, protected policy/audit state, and a supported update path.

The first release therefore does not require custom privileged software. docker compose config is still useful: it renders interpolation and merged files into the model that must be reviewed, and can emit canonical JSON and an image-digest lock override. It validates Compose semantics; it does not prove that mounts, paths, authorization, concurrency, or command construction are safe. Docker: docker compose config

Every rendered model is rejected before the human packet if it contains:

  • privileged: true;
  • Docker, containerd, /, /boot, /etc, /proc, /sys, or an unrestricted /dev mount;
  • pid: host, ipc: host, and unapproved network_mode: host;
  • new devices, cap_add, security-profile removal, or no-new-privileges:false without an explicit exception decision;
  • a host port absent from the readiness sheet;
  • a bind source outside the project's approved state, data, and scratch roots;
  • a floating image tag, unapproved registry, local build: section, or unresolved image digest;
  • a secret source outside the protected secret staging area;
  • another project's state;
  • a destructive Compose operation such as volume removal.

A future fixed action must enforce those decisions itself rather than trusting the agent's validator. It must also canonicalize paths and reject symlink escapes; pin and revalidate release digests at execution; bind authorization to a human-issued grant with expiry, attempt limits, and maintenance freezes; prevent replay and concurrent conflicting runs; recover safely after a crash; redact output; and make its own code, policy, audit, and update path unwritable by callers. Until those properties are implemented and independently reviewed, remain at rung 2.

Admission tests for day one#

TL;DR: Prove forbidden Docker, sudo, mount, listener, secret, and active-release paths fail before deploying a real service.

Before a real service is deployed, observe these tests:

  1. The agent's raw docker ps fails.
  2. Reading or writing the Docker socket fails.
  3. Ordinary sudo and a root shell fail.
  4. The agent and credential-free reviewer both identify synthetic releases containing privilege, forbidden mounts, devices, or undeclared listeners, and the human packet is never run. Do not call this infrastructure enforcement.
  5. The human packet identifies every root action before any is run; there is no arbitrary trailing shell.
  6. A valid disposable project deploys through that one packet, passes an external health test, restarts, and rolls back.
  7. Agent-visible logs and validator output reveal no synthetic canary or real secret.
  8. The agent cannot alter the root-owned active release, recovery point, or audit record.

Do not trade these tests for a paragraph in an agent rules file. Do not claim infrastructure enforcement where the only enforcement is still human review.

Limit which networks a container can reach#

TL;DR: Explicitly constrain and test container access to the Internet, management LAN, backups, overlays, and confidential mounted data.

A user-defined Docker bridge isolates unrelated container networks, but by default it also uses masquerading to give its containers external network access. “No published port” prevents ordinary inbound access; it does not prevent a compromised image from probing the router, NAS, PDU, Mac, or Internet. Docker documents this default in its bridge-network reference.

Before the first real service:

  • Give each Compose project its own named networks; never leave it on the shared default bridge.
  • Mark database/backend-only networks internal: true when their members need no external path.
  • Record each Internet and LAN dependency in the readiness sheet.
  • Deny container bridge ranges from initiating toward Tier 0/1, management, backup, human-client, and overlay address ranges, then add exact service exceptions such as DNS or a named local dependency.
  • Apply the rule for both IPv4 and IPv6 and test from inside a disposable container.
  • Keep confidential data read-only or absent from any container whose Internet egress is not constrained.

Docker and UFW do not compose into this policy automatically: published traffic can bypass the UFW paths an owner expects. Docker documents its firewall behavior and the DOCKER-USER hook for its iptables backend in Packet filtering and firewalls. Firewall backends and syntax vary, so the agent must inspect the installed Docker/backend state and prepare a timed-rollback human packet rather than paste a generic ruleset.

If the host cannot enforce and test “Internet as needed, no management LAN” safely, do not put a public-facing or untrusted-image workload beside critical data on that flat host. Use a separate low-trust VM/machine or wait for a router/VLAN upgrade. Egress by public hostname generally requires a controlled proxy or frequently maintained address policy; do not pretend a one-time IP list solves it.

First exercise: a one-page service with no stored data#

TL;DR: Learn image, port, network, health, resource, and cleanup behavior with a disposable service containing no valuable state.

Work profile: operational size small; quota small; human effort small; agent effort medium; wait small; outage none; disposable service only; no storage/household disruption; clock duration unknown until target-specific evidence exists.

Run this disposable lesson before designing Plex, a database, or an ingress proxy. Its purpose is to make nine words concrete while the blast radius is tiny. It contains no household data, secret, device, database, volume, LAN listener, or required Internet egress.

Download compose.yaml, or have the agent reproduce it exactly in a new disposable directory:

name: compose-lesson

services:
  page:
    image: docker.io/nginxinc/nginx-unprivileged:stable-alpine@sha256:9b87ad3dd9f431c733f19dfb278c7eb3dba9dca381942c79818bb42f1a566a83
    restart: "no"
    read_only: true
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    cpus: 0.25
    mem_limit: 128m
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=16m
    ports:
      - "127.0.0.1:18080:8080"
    networks: [lesson]
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1"]
      interval: 5s
      timeout: 2s
      retries: 6
      start_period: 5s

networks:
  lesson:
    internal: true

The manifest-list digest above was verified on 4 September 2026. The image tag tells a human what release channel it came from; the digest tells Docker the exact immutable content to run across supported architectures. Before using the exercise later, the agent compares the current publisher digest and release provenance, proposes an update if needed, and records the new digest. It never silently deletes @sha256:....

WordMeaning in this lesson
ImageThe read-only packaged NGINX filesystem and process definition downloaded from the registry
ContainerThe temporary running instance made from that image
Projectcompose-lesson: the lifecycle boundary Compose starts and removes as a unit
PortServer loopback TCP 18080 is forwarded to container TCP 8080; no LAN interface is bound
NetworkA project-private bridge marked internal, so the page has no ordinary external route
MountA host path inserted into a container; there is deliberately none
VolumeDocker-managed persistent storage; there is deliberately none
Health checkA question asked inside the container; it reports health but does not repair the process
SecretSensitive runtime input; there is deliberately none
DigestThe immutable content identifier that prevents a tag from changing what runs

The agent, without Docker authority, first runs docker compose config --quiet, inspects the complete model, and reports the source file's SHA-256. Validation and privileged execution must use the same bytes. The current downloadable file hashes to 0749ed759da1741688320959ab78aaeb088239a0ac3c14eb626e40a31a5b91ae; if the file differs, stop and review the new file rather than changing the expected hash casually.

Download the lesson script, reviewed helper, and Compose file into the same human-only directory on the Linux server. The published script SHA-256 is 4589ec3c878fae5162668af4bc2c266e307eefba37f21e2170b8eea38d47b3e8. The implementation agent must replace the example below with the real absolute path and real denied agent account, then wrap that exact command in the Chapter 1 actor/host/account/expected-result/resume-condition envelope before presenting it as NEXT ACTION:

sudo /bin/bash /absolute/human-only/path/run-compose-lesson.sh operator-observer

That is the only command the owner runs. The script first checks the sudo caller, argument, bootstrap tools, source-directory chain, and source file owner, mode, type, and digests. It opens the helper and Compose input through file descriptors, verifies their reviewed digests, and runs the helper's pure classifiers. It then checks the evidence tools and protected log directory. Failures during these prerequisite and source-integrity checks may appear only in the terminal because the per-run log does not exist yet. After those checks pass, the script opens a unique log before Docker, socket, port, and lesson-state checks. It refuses an unknown process on the lesson port. Before inspecting or reconciling this lesson's labeled Docker objects, it supplies Docker Compose only a root-owned sealed copy. A rerun removes only a release or staging directory whose sole file, digest, type, owner, and mode exactly match the disposable lesson. It validates the sealed model, pulls the image, deploys and restart-tests the page, checks it on the same computer, proves the named account switch works, and accepts the Docker denial only when the captured error names the reviewed Docker socket rather than an unrelated permission error.

Failure after mutation captures compose ps and the last 200 log lines before the same invocation attempts its already-approved disposable cleanup. The final failure state distinguishes nothing started, cleanup complete, and cleanup partial; a partial result names remaining project objects, port state, and release paths. Successful cleanup removes the disposable release as well as its container and network so a clean second run can succeed. SUCCESS is reserved for the final line after cleanup and all final-state checks pass. The evidence footer tells the owner to stop and share the existing log; it never supplies or requests a second cleanup command. These checks still require execution on the real target: static validation cannot prove daemon behavior, effective socket denial, listener implementation, health, teardown, or filesystem postconditions.

The lesson is complete when that script succeeds and the owner can explain why changing the port to 18080:8080, adding a host mount, removing internal: true, or granting Docker-group membership would widen access.

Give every service a readiness sheet#

TL;DR: Refuse deployment until purpose, ownership, exposure, dependencies, data, recovery, limits, tests, and retirement are recorded.

No sheet, no deployment. The sheet is the compact contract from which the agent produces Compose, ingress, backup, monitoring, and permissions. It also prevents a new session from inventing the reason a service exists.

FieldWhat must be recordedHard question
service_idStable, clear identifierWill this name still make sense after a hardware replacement?
purposeOne sentence describing the household outcomeWhat breaks for a person if it stops?
ownerHuman accountable for policy and costsWho decides whether to keep it?
tierOutcome tier, not perceived technical importanceDoes its failure affect access, internet, data safety, household function, or only convenience?
users_and_groupsNamed people and application rolesDo children, adults, guests, or only operators need it?
exposurePublic, LAN/VPN, VPN-only, or independent recoveryWhy must each reachable path exist?
authenticationLocal, OIDC, proxy, protocol-specific, or noneWhat still works if SSO or internet fails?
admin_pathVPN-only management URL or protocolIs administration separable from ordinary use?
life_safety_and_manual_controlSafety relevance, independent protection, manual override, fail-safe state, and agent-actuation ruleWhat remains safe and operable when this service, the LAN, internet, SSO, or model is down?
dependenciesDNS, database, storage, internet, identity, another serviceWhat is the observed readiness condition for each?
ports_and_protocolsListener, source networks, and consumerIs a direct host port actually required?
egressNone, exact local destinations, and required Internet behaviorCan a compromised image probe management or exfiltrate mounted data?
service_identityUID, GID, groups, API scopesWhat is the minimum filesystem and API access?
persistent_stateExact paths/volumes and data classWhich bytes must survive container replacement?
bulk_dataExact read/write mountsCan the service be read-only?
scratchPath, size ceiling, deletion ruleCan this fill the root filesystem?
secretsReferences, issuer, rotation and revocationCan the service read any secret it does not need?
backupApplication-consistent method, RPO, retention ownerHas it been restored, not merely copied?
upgradeSource, pinning, cadence, migration behaviorCan the previous version read the upgraded state?
healthInternal readiness plus independent user-visible probeWhat proves the outcome, not just the process?
resourcesNormal/peak CPU, RAM, I/O, GPU, network and limitsWhich contention could harm a lower-numbered foundational tier?
execution_boundaryUnprivileged agent steps, consolidated human packet, and any independently reviewed fixed actionsWhat can proceed without a human because the target—not the prompt—enforces it?
acceptance_testsClient, restart, failure and access-control testsWhat observation closes the change?
retirementExport, deletion and secret-revocation planHow will this service leave cleanly?

Human and agent roles#

TL;DR: Humans decide policy and authority; agents discover, draft, validate, and verify only within target-enforced boundaries.

The owner decides purpose, users, tier, privacy, public exposure, subscriptions, content policy, downtime, and whether household monitoring is acceptable. The owner enters secret values directly into their store and authorizes the first privileged bootstrap packet.

The agent discovers observable facts, drafts the sheet, identifies unknowns, reads current primary documentation and release notes, proposes the simplest design, generates desired state, validates it, and prepares the consolidated privileged packet. After the human invokes that packet, the agent runs unprivileged and client-side tests and records evidence. A reviewed fixed action may later replace only the exact human step it implements.

The agent stops for a new public listener, new filesystem writer, new device or capability, irreversible migration, reduced backup retention, child-monitoring change, safety-relevant home automation, failed rollback prerequisite, or any privilege not contained in the approved packet. It does not stop between ordinary read-only discovery, rendering, validation, external tests, and documentation inside the approved phase.

Raw logs, support posts, READMEs, release notes, image labels, and web pages can contain adversarial instructions. On day one the researching agent has no mutation credential, so the human boundary contains that risk. If a future deployer can invoke a fixed action, do not also give that principal arbitrary web browsing and raw log ingestion. Use a credential-free reader/reviewer to produce a small, source-attributed fact packet—versions, changed settings, migration requirements, checksums, and unresolved claims—and have the deploy path accept only the fields its schema requires. Treat the reader's prose as untrusted evidence, never as executable shell.

Keep application data easy to move and restore#

TL;DR: Use predictable separate roots for durable state, bulk data, immutable releases, scratch space, and runtime secrets.

Use a small number of explicit roots. The exact mount point may differ, but the meanings must not:

/srv/homelab/state/PROJECT/SERVICE/    application state that is backed up
/srv/homelab/data/CLASS/               household data such as media or documents
/srv/homelab/releases/PROJECT/REV/     immutable, root-owned rendered releases
/srv/homelab/current/PROJECT           root-owned pointer to the active release
/var/cache/homelab/PROJECT/            bounded, disposable working data
/run/homelab-secrets/PROJECT/          runtime secret files; never in Git

Parent directories are root-owned. Each state directory is created once with the recorded service UID/GID and the narrowest workable mode. Do not “fix” a permissions error recursively with chmod 777 or ownership changes over the entire tree.

The default uses explicit bind mounts for state because the paths are easy to inventory, snapshot, and include in backup policy. Docker-managed volumes are acceptable when an application or supported image expects them, but they still require a named, tested export and restore procedure. Docker notes that directly manipulating a named volume's backing directory is unsupported; interact with it through a container or Docker's volume interface. Docker: Storage

Use Compose's long mount syntax and bind.create_host_path: false for every persistent or remote-backed path. Short bind syntax creates a missing source directory. That can turn a late NFS mount into an empty local directory and allow an application to start against the wrong storage. With create_host_path: false, the deployment fails instead. Docker Compose service reference

The preflight in the human packet asserts that each mount has the expected filesystem, source, and mount ID immediately before deployment. A path existing is not evidence that the intended disk or NFS export is mounted there. A future fixed action must repeat this check at execution rather than trust an earlier agent result.

Data rules#

TL;DR: Keep images disposable, state explicit and backed up, databases local and isolated, bulk data least-privileged, and scratch bounded.

  • The container image and writable container layer are disposable.
  • Application configuration and databases live under state; they are never kept only inside a container.
  • Media consumers receive read-only mounts.
  • A database receives its own state path and no bulk household-data mount.
  • Scratch and transcode paths have quotas or a monitored ceiling and may be erased during recovery.
  • SQLite and other lock-sensitive databases stay on a local filesystem unless the application explicitly supports the chosen network filesystem. Plex's official container documentation requires its configuration filesystem to support file locking and warns that SMB-backed configuration can corrupt its database. Plex official container
  • A storage snapshot is not automatically application-consistent. Use the application's backup/export mechanism or a documented quiesce sequence for databases.
  • The runtime may create backups; it cannot prune the last independent history.

A safe starting Compose file#

TL;DR: Derive each hardened Compose definition from the readiness sheet rather than copying the illustrative fragment unchanged.

This fragment is a policy example, not a runnable application. Replace the synthetic image, digest, paths, UID/GID, probe, and limits with values from the readiness sheet.

name: recipe-catalog

services:
  app:
    image: ghcr.io/example/recipe-catalog:1.8.3@sha256:REPLACE_WITH_RESOLVED_DIGEST
    restart: unless-stopped
    user: "REPLACE_WITH_RECORDED_SERVICE_UID:REPLACE_WITH_RECORDED_SERVICE_GID"
    read_only: true
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 256
    cpus: 1.0
    mem_limit: 1g
    stop_grace_period: 30s
    environment:
      TZ: REPLACE_WITH_IANA_TIME_ZONE
      DATABASE_HOST: db
      DATABASE_PASSWORD_FILE: /run/secrets/database_password
    secrets:
      - database_password
    volumes:
      - type: bind
        source: /srv/homelab/state/recipe-catalog/app
        target: /var/lib/recipe-catalog
        bind:
          create_host_path: false
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 67108864
    ports:
      - "127.0.0.1:18120:8080"
    networks: [front, back]
    depends_on:
      db:
        condition: service_healthy
        restart: true
    healthcheck:
      test: ["CMD", "/app/healthcheck"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

  db:
    image: docker.io/library/postgres:REPLACE_WITH_SUPPORTED_VERSION@sha256:REPLACE_WITH_RESOLVED_DIGEST
    restart: unless-stopped
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 256
    mem_limit: 1g
    stop_grace_period: 60s
    environment:
      POSTGRES_DB: recipe_catalog
      POSTGRES_USER: recipe_catalog
      POSTGRES_PASSWORD_FILE: /run/secrets/database_password
    secrets:
      - database_password
    volumes:
      - type: bind
        source: /srv/homelab/state/recipe-catalog/postgres
        target: /var/lib/postgresql/data
        bind:
          create_host_path: false
    networks: [back]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U recipe_catalog -d recipe_catalog"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 30s

networks:
  front: {}
  back:
    internal: true

secrets:
  database_password:
    file: /run/homelab-secrets/recipe-catalog/database_password

The security fields are a starting posture, not cargo cult. Some supported images need a writable path, a different user, or a specific capability during initialization. Add only the documented exception, attach it to the project policy, and test it. Do not disable all confinement because one image failed to start.

The example's restart: unless-stopped is a deliberate low-tier process-supervision policy so a proven service returns after a daemon or host restart. It is not health-triggered remediation and it can retry an exiting process indefinitely, with Docker's backoff. Before using it, make the application exit only on a genuine unrecoverable condition, monitor restart count/rate, preserve logs before rotation, and alert when the recorded restart budget is exceeded. For a workload whose repeated crash could harm storage, lower tiers, or another dependency, use a bounded on-failure:N policy and a separately designed boot-start mechanism, or leave restart disabled; record and test the choice in the readiness sheet. Never add a second watchdog that fights Docker's policy.

Compose secrets are mounted as files and granted per service, which is preferable to placing passwords in environment variables that can leak through debugging and logs. Compose does not create an encrypted secret store: the source file still needs host-side protection and rotation. Docker: Manage secrets securely in Compose

Do not use:

  • latest, public, or another floating channel for an unattended service;
  • container_name, static container IPs, or cross-project references to ephemeral IPs;
  • host source-code mounts in a deployed service;
  • a single giant Compose file for unrelated applications;
  • a shared database server merely to reduce the container count;
  • network_mode: host, privileged, or a device mapping without an approved readiness-sheet exception;
  • environment-variable secrets when the image supports file-based secrets;
  • an automatic updater that replaces images without backup, release-note review, health verification, and rollback state.

Compose gives services stable DNS names within a project; container IPs can change when a service is recreated. Use the service name, not a recorded container address. Keep databases only on their internal application network and do not publish their ports. Docker: Networking in Compose

Set Docker's bounded local logging driver as the host default before deploying real services, then recreate the disposable test container and confirm its effective driver. Docker recommends local for ordinary cases because it rotates and compresses by default; the unrotated default json-file driver can exhaust disk. A service that needs longer retention sends selected events to the monitoring design—it does not keep an unlimited local log. Docker: Configure logging drivers

Deploy one complete release at a time#

TL;DR: Freeze intent, resolve and seal exact artifacts, protect data, deploy one project, test externally, and record the result.

Work profile: operational size large; quota large; human effort medium; agent effort large; wait large; outage planned per-service outage; declared service/state paths only; no unrelated layer change; clock duration unknown until target-specific evidence exists.

The deployment sequence is the same whether the change came from the owner, Claude, Codex, Gemini, or a future model. Steps 4–15 are implemented by one digest-bound release artifact and one exact invocation. That artifact runs language and fixture checks, applies one release, performs native, live, client, denial, and data checks, records the evidence, and reports success only at the end. On failure, the same invocation may perform only its sealed, already-authorized transaction-local cleanup. It then freezes the change and asks the owner about any different recovery artifact; it does not drip-feed commands.

  1. Freeze the decision. The readiness sheet, acceptance tests, permitted outage, and rollback trigger become the change contract.
  2. Create a proposed release. The agent changes versioned desired state in Git. Secret references are present; values are not.
  3. Read upstream evidence. The agent reads release notes, upgrade notes, supported image documentation, and known migration requirements. It distinguishes security-only patches from behavior changes.
  4. Render the dependency closure. In the unprivileged workspace, resolve every Compose file, include, override, profile, and non-secret variable into one canonical deployable file. Runtime secrets remain references to protected files; secret values never enter the model, rendered file, or Git. Missing required inputs fail closed.
  5. Validate policy. Compare the rendered model with the approved readiness sheet and reject undeclared authority. A human reviews this result until a fixed action independently enforces it.
  6. Resolve artifacts. Resolve the publisher's registry digest without starting the image and produce a digest lock. The later human packet pulls that exact digest on the host. The host does not build application images from mutable source.
  7. Seal the reviewed bytes. The human packet copies the rendered file and its digest lock into a new root-owned, agent-non-writable release directory, then verifies their recorded hashes there. Every privileged Compose call names that exact file, project name, project directory, and empty or protected explicit environment file. It must not rediscover .env, override, or include state from the proposal workspace.
  8. Preflight data. Classify every persistent path. Add every policy-protected path to the readiness sheet, backup matrix, and installed scheduled job before apply. Record an intentionally omitted cache, scratch, stateless, or replaceable path as not-applicable with its class and reason. For an existing service, verify that its installed job selects every protected path. For a new path that does not exist yet, select it in the job and mark capture evidence pending; do not pretend a backup already ran. Verify mount identities, ownership, free-space floor, the previous image, and the application-consistent recovery method. If a database migration is not backward-compatible, record restore—not mere image rollback—as the recovery path.
  9. Take and prove the pre-apply recovery point. For an existing service, before apply or migration, run its application-consistent method through the installed job and repository named by the record. Prove that the job selected every protected path, restore a representative fixture from that repository, and record the evidence. A brand-new service has no pre-apply state; its new-path proof remains pending. If there are no policy-protected paths, record why backup is not applicable instead of inventing a job or restore result. An ad hoc local copy does not prove the recurring backup. Do not let the deploy identity prune it.
  10. Apply one project. Execute only from the sealed release. Do not recreate unrelated services. Do not combine host packages, firewall rules, storage changes, and an application upgrade in one change. For a brand-new service with policy-protected data, apply may create only empty or disposable fixture state. Before the service accepts valuable or authoritative input, run its installed scheduled job, prove that the new path was selected, restore a representative fixture from the named repository, and replace the pending evidence in Steps 8–9 with the result.
  11. Test locally. Check dependency readiness, application health, logs, and resource use.
  12. Test as a client. A separate process or host exercises the real name, TLS path, authentication, and one representative user operation.
  13. Test denial. Confirm an unauthorized household identity and an out-of-scope network cannot use the service or its admin path.
  14. Close or offer recovery. Success means the outcome-level tests pass for the observation window. On failure, record any already-authorized cleanup performed inside the sealed invocation, freeze further mutation, and preserve the releases and evidence. Recommend a separately prepared last-known-good rollback unless facts argue otherwise, state how it could fail, and ask the owner to choose rollback, hold, or a separately reviewed fix-forward.
  15. Verify the chosen recovery. Transaction-local cleanup is not the owner's later rollback choice. If the owner chooses a new rollback, invoke its separately named packet and repeat native, client, denial, data, and health tests. Record the chosen action, applied version, digest, evidence, and current state. A restored file or old image is not recovery until the user-visible outcome passes.

Do not treat docker compose up -d returning zero as completion. It says Docker accepted work, not that a family member can use the service.

Update cadence#

TL;DR: Discover updates freely but apply pinned releases only through reviewed, recoverable authority that stops on changed risk.

Agents may discover and prepare updates in the background. On day one, the human applies the consolidated packet. Later, an agent may apply only the exact routine update exposed by an independently reviewed fixed action or protected control plane; an “approved maintenance policy” in prose does not confer Docker authority. Both paths stop when release notes require a new privilege, destructive migration, new external dependency, or changed user-visible behavior.

Do not install Watchtower or an equivalent blind updater on the baseline. A new image can contain a schema migration, changed UID, broken client support, or new port. “Automatically current” is not the same as recoverable.

Do not invent an identical staging stack that the homelab does not own. Use a disposable instance or lower-tier canary only when it genuinely represents the risky behavior. Otherwise preserve the current image digest, configuration, application-consistent backup, and restore procedure; update one Compose project; validate its real name, TLS, login, representative read/write operation, backup, and denial behavior; then record actual results before touching another project. An owner-run host or client package upgrade is reconciled the same way rather than treated as forbidden drift.

Use one web entry point#

TL;DR: Use one host-level Caddy service for web ingress so applications need no public listeners or Docker-socket proxy.

Install Caddy from its official operating-system package and run it as the host's caddy systemd service. It owns TCP 80 and 443. Application containers stay on loopback ports. Caddy recommends a system service on Linux, and its official packages supply one. Caddy: Install and Caddy: Keep Caddy running

Use one installation artifact that fixes the signed repository, package, binary, service, and base-configuration closure. It checks its own language and fixtures, simulates the package transaction, installs Caddy, validates the base Caddyfile, verifies service state and listeners, and proves both the intended HTTP/TLS path and an expected denial before reporting success.

Each web application publishes one backend port on loopback, for example 127.0.0.1:18120. Caddy proxies a durable internal name such as recipes.home.arpa to that port. Nothing else on the LAN can bypass Caddy and reach the backend directly.

Why host-level rather than a label-driven proxy container?

  • It does not need the Docker socket.
  • It can keep presenting a clear error while an application project is being replaced.
  • Its privileged listeners and certificate files remain outside agent-controlled application manifests.
  • A later fixed Caddy action can validate and reload this one small routing layer without granting Docker or arbitrary root access.

Caddy can validate an adapted Caddyfile without starting it. Each later route change uses one artifact that stages the complete route closure, tests any templates or regex fixtures, runs caddy adapt --validate, seals and revalidates the reviewed bytes, installs them atomically, reloads Caddy, and tests the real name, TLS, authentication path, backend, and expected denials before reporting success. Caddy command-line validation

Every route has a record:

hostname: recipes.home.arpa
backend: 127.0.0.1:18120
exposure: lan-vpn
authentication: application-local
admin_path: vpn-only
websocket: false
body_limit: 32MiB
owner: household-adults

The network and PKI chapter determines whether Caddy receives a certificate from the household CA or manages a local issuer. Do not let the presence of a real public domain silently cause public certificate issuance for private service names. Caddy automatically manages certificates when a qualifying hostname appears in configuration; internal names require client trust in the chosen private CA. Caddy: Automatic HTTPS

Exposure rules#

TL;DR: Default applications to LAN/VPN, keep administration VPN-only, retain application authentication, and review public exposure separately.

  • Default: web applications are reachable from LAN and the owner VPN, not the public internet.
  • Management planes are VPN-only even when ordinary service use is public.
  • Public exposure is a separate owner decision, firewall change, threat review, rate-limit decision, and external test.
  • A reverse proxy is not an authorization system. Keep application authentication enabled.
  • Do not turn off upstream certificate verification to make a warning disappear. If the proxy-to-backend hop uses TLS, give Caddy the correct trust root and server name; otherwise use HTTP on the protected loopback hop. Caddy explicitly warns against tls_insecure_skip_verify. Caddy reverse proxy
  • Protocols such as SMB, NFS, media discovery, and device discovery do not belong behind an HTTP reverse proxy.

Add single sign-on after local login works#

TL;DR: Introduce SSO gradually after every critical service has tested local and break-glass recovery independent of it.

SSO reduces repeated login and centralizes account lifecycle. It also creates a new dependency. Do not deploy an identity provider as the first application and then make every recovery page depend on it.

The sequence is:

  1. Create named local accounts and a stored break-glass administrator for each service.
  2. Verify local login and recovery while the internet and identity provider are unavailable.
  3. When at least two or three browser applications make central identity worthwhile, deploy authentik as its own project with its own database, backup, health test, and recovery account.
  4. Integrate one disposable application first.
  5. Prefer the application's native OIDC support. Native integration understands sessions, logout, groups, API access, and often mobile clients better than a generic proxy barrier.
  6. Use authentik forward auth only for a browser-only application that lacks native OIDC. Use a single-application provider so each application retains its own policy; authentik's domain-level mode cannot apply distinct per-application authorization rules. authentik: Forward auth
  7. Test normal login, MFA, logout, group removal, expired session, identity-provider outage, local break-glass login, API clients, and mobile clients.
  8. Migrate additional applications one at a time.

authentik supports OIDC as an identity provider. That does not make every application safe to expose publicly, and it does not replace SMB/NFS identities or Linux UID/GID alignment. authentik: OAuth 2.0/OIDC provider

The official authentik Compose template mounts the Docker socket for automatic outpost management and documents removing it in favor of manually managed outposts. Remove that mount. The convenience is not worth giving the identity system control of the host daemon. authentik: Docker Compose installation

Before upgrading authentik, back up its database and follow sequential-version requirements; its documentation warns that downgrade is not supported. An image rollback alone is therefore not a complete recovery plan. authentik: Upgrade

Never place these paths exclusively behind SSO:

  • local Tier 0 access;
  • the VPN or its independent recovery path;
  • the secret store's recovery procedure;
  • the identity provider's own break-glass login;
  • server console, SSH recovery, backup restore, DNS/DHCP recovery, UPS, or PDU control.

Give each household member an ordinary account#

TL;DR: Give adults, children, guests, service operators, and infrastructure administrators separate least-privileged identities.

A flat home LAN is not a trusted management network. A child's game mod, an adult's malicious browser extension, a visiting laptop, or a compromised television can all originate “inside.” Application and host boundaries must survive that fact.

Create these roles even if one person initially fills several:

  • household-adults: ordinary use of household services;
  • household-kids: age-appropriate, explicitly granted use;
  • service-operators: change settings inside selected applications;
  • infrastructure-admins: host, storage, network, identity, backup and power control;
  • one service account per application integration;
  • one non-SSO break-glass identity per critical control plane.

Adults belong to household-adults, not automatically to infrastructure-admins. When an adult needs to administer Plex or Home Assistant, use a separate administrative identity or deliberate elevation. A stolen everyday session must not become a server administrator.

Children receive named or managed accounts, not a shared adult password. Grant specific libraries and functions. Disable downloads, deletion, purchasing, plug-in installation, automation editing, and account switching unless each is deliberately wanted. Monitoring of children must be transparent, proportionate, and an owner policy decision—not an agent's inferred feature.

No household client can write:

  • Compose releases or ingress configuration;
  • application configuration directories except through the application's authenticated API;
  • backup repositories, snapshot policy, or audit records;
  • media-manager configuration or downloader credentials;
  • SSH keys, CA keys, identity-provider state, or agent grants.

This separation matters more than whether every web page has the same login screen.

Phase 5 is complete when#

TL;DR: Stabilize and observe the hardened application platform before optional household workloads add complexity.

Phase 5 establishes the application platform; it does not require Plex, Home Assistant, SSO, a downloader, or Kubernetes. Stop and live with this foundation before adding Phase 6 workloads.

Phase 5 passes when:

  • Docker Engine and the Compose plugin came from the recorded official repository, return after reboot, use bounded logging, and expose no surprise listener.
  • No ordinary human or agent account belongs to docker; raw Docker, sudo, socket, forbidden-mount, device, and listener tests fail.
  • The disposable page deploys, becomes healthy, restarts, and disappears through one consolidated human packet while the agent renders and verifies without a dialog.
  • Container ingress and egress behavior has been measured for IPv4 and IPv6; Tier 0/1 and management destinations are denied unless an exact dependency says otherwise.
  • The data roots, mount-absence failure, secret-staging boundary, image-digest rule, update transaction, and rollback packet have been tested without valuable data.
  • Caddy exists only if a selected web service needs it; if present, backends are loopback-only and a client-side name/TLS/authentication test passes.
  • A readiness sheet and human packet exist for the first real workload. Nothing optional was installed merely to satisfy the guide.

This is a useful stopping point. The soak time is unknown until the real stack is running. Before adding media or home automation, complete at least one reboot and representative DNS, mount, logging, and remote-access checks; record how long the soak lasted and what failed.

Phase 6 — Add selected household services in this order#

TL;DR: Add only selected services, ordered by household value and dependency, after the disposable platform passes.

Work profile: operational size XL; quota XL; human effort large; agent effort XL; wait XL; outage planned selected-service outage; Tier 3/4 network/storage/household effects; lower tiers prohibited; clock duration unknown until target-specific evidence exists.

After the disposable service passes deployment, restart, denial, and restore tests, add real workloads by outcome tier:

  1. A Tier 3 Home Assistant deployment, if the household already has a concrete automation use case.
  2. Plex, initially LAN-only and direct-play-first.
  3. Remote Plex only after measuring upload and client behavior.
  4. Optional media automation only after Plex's paths, accounts, backup, and playback are stable.
  5. Authorized peer transfer as a separate design, never as an improvised extension of Plex or backup.

If home automation is merely aspirational, do not install it “for later.” Plex can be the first real service. Just do not let a convenience workload displace a higher-tier one during contention.

Set up Plex and measure real playback#

TL;DR: Optimize clients and media for direct play before treating transcoding hardware as a requirement.

Plex creates three different workloads:

  • Direct Play: the client accepts the file as stored; server work is nearly zero.
  • Direct Stream: streams are repackaged into a compatible container; processing is light and quality is unchanged.
  • Transcode: incompatible video, audio, bitrate, resolution, or subtitles are converted; this can be CPU/GPU intensive.

Subtitle choice can force a full video transcode even when the underlying media would otherwise play directly. Plex: Streaming Media—Direct Play and Direct Stream

Wait for playback evidence before buying a GPU#

TL;DR: Measure actual clients, formats, concurrency, upload, power, and failed transcodes before purchasing acceleration hardware.

Inventory and test first:

QuestionRecord
Playback clientsExact TV, streaming box, phone, tablet, browser and remote devices
Typical mediaContainer, video/audio codecs, resolution, HDR, bitrate and subtitle formats
ConcurrencyRealistic local and remote simultaneous streams, not a fantasy maximum
Remote needWho needs it, from which client, and at what acceptable quality
Upload floorSeveral wired tests at different times; use the conservative observed floor
Power/noise budgetIdle and transcode power, available slot, case depth and cooling
SubscriptionCurrent Plex remote-play and hardware-transcoding requirements
AcceptanceExact files and clients that must play without buffering

The initial capacity target is direct play for normal household use and at most one ordinary 1080p transcode. Enable Direct Play and Direct Stream on clients. Prefer compatible playback clients and media before adding server hardware. Plex explains that compatibility depends on container, codecs, bitrate, resolution, audio, and subtitles—not simply the filename extension. Plex: Direct Play, Direct Stream, Transcoding Overview

Only buy transcoding hardware after the Plex dashboard shows repeated real transcodes that the current server cannot sustain under the agreed workload. If hardware acceleration is then justified, prefer an efficient supported integrated GPU—commonly recent Intel Quick Sync—before a large discrete card in a quiet shallow server. Plex documents current platform requirements and notes that hardware acceleration requires an active Plex Pass. Recheck this at purchase time. Plex: Hardware-accelerated streaming

Plex deployment boundaries#

TL;DR: Pin Plex, isolate and back up configuration, mount libraries read-only, bound scratch, and grant only proven devices and ports.

  • Use Plex's official container image, pinned to an exact release and digest. Do not use its floating public or beta channel for unattended deployment.
  • Keep /config on a local, lock-capable filesystem and back it up using Plex's documented state procedure.
  • Mount movie, television, music, and home-video libraries read-only into Plex.
  • Give the transcode directory its own bounded scratch space. It may be deleted; it may not fill the OS or application-state filesystem.
  • Do not give Plex the downloader's credentials, Docker socket, library write access, or a general host device tree.
  • Begin without a GPU device. If measurement justifies acceleration, authorize only the required render device and re-run the privilege and denial tests.
  • Treat a Plex claim token as a short-lived setup secret. Inject it for claiming, verify ownership, then remove it from the release and secret staging area.
  • Default to bridge networking with explicit required ports. Publish TCP 32400 on the server's LAN address for native Plex clients; add only the documented discovery ports the tested clients actually need. Docker warns that omitting the host address binds a published port on all interfaces and can bypass some host-firewall expectations, so verify the listener from LAN, VPN, and an outside network. Plex's official image documents bridge, host, and macvlan modes and notes that bridge requires more setup. Use host networking only if a recorded discovery/client requirement fails under bridge and its broader network access is accepted. Plex official Docker image and Docker Compose port publishing

Local first, remote second#

TL;DR: Prove LAN playback, accounts, recovery, resources, and isolation before intentionally opening remote Plex access.

Pass these LAN tests before opening remote access:

  1. Play representative H.264 and HEVC files on the main television at original quality.
  2. Play one file with the household's usual audio path.
  3. Exercise text and image-based subtitles and note whether video transcodes.
  4. Force one 1080p transcode and record speed, CPU/GPU use, temperature, scratch growth, and whether a higher-tier service suffers.
  5. Restart Plex, then reboot the host in a maintenance window. Confirm library state and client reconnection.
  6. Restore Plex state into a disposable instance and confirm the library can be read.

Remote streaming consumes the home's upload bandwidth. Measure the conservative floor and assign Plex only a portion of it, leaving headroom for calls, games, backups, VPN administration, and normal browsing. Plex's bandwidth setting intentionally uses only 80% of the value entered to allow for bitrate variation and other requests. Plex: Bandwidth and transcoding limits

Default remote policy:

  • Keep Plex LAN/VPN-only unless a real remote client cannot use the owner VPN.
  • If ordinary remote Plex clients are required, use Plex's supported remote-access path and one explicit manual router mapping. Keep router UPnP/NAT-PMP disabled.
  • Record TCP 32400 as a mixed user/control-plane exception, not a pure streaming port: Plex Web and server administration use the same server endpoint. The public mapping therefore contradicts the normal “management is VPN-only” rule by design. If the owner will not accept that exception, keep Plex VPN-only. Plex documents opening its web application through the server address and port. Plex: Opening Plex Web App
  • Protect the server-owning Plex account with a unique password and MFA, never share its session or token with household users, and test from outside that an unprivileged user cannot reach server settings. A reverse proxy hostname does not separate administration from streaming on this endpoint.
  • Do not publish a descriptive plex.example.net management hostname merely for convenience.
  • Set a conservative total upload allocation, per-stream quality, and per-user stream limit.
  • Recheck current Plex subscription requirements before promising remote use; the product's remote-play terms have changed over time. Plex: Remote streaming setup
  • Test from a genuinely external network while another household workload is using the uplink.

Plex household accounts#

TL;DR: Give each person a constrained account and explicitly grant libraries and child capabilities instead of sharing adult credentials.

Use a separate regular Plex account for each adult and managed accounts for younger children. Share explicit libraries rather than “all libraries,” so a future library is not silently exposed. Disable Downloads for children by default. Plex supports managed-user restriction profiles and per-library grants. Plex: Managed Accounts and Plex: Restrictions on Library Access

Protect adult profiles with PINs on shared television clients, but do not mistake the PIN for strong security; Plex describes it as a convenience control. A full account that can administer the server needs a real password and MFA where available. Plex: Consequences of being in a Plex Home

Run Home Assistant in its supported appliance#

TL;DR: Prefer dedicated Home Assistant OS because hardware integration and household continuity justify separation from the general Compose host.

The default is Home Assistant OS on dedicated supported hardware, not another container in the general Compose host. If a hypervisor was independently selected, built, and recovery-tested for other reasons, a dedicated HAOS VM is also a good target. Do not add a hypervisor to this bare-metal server merely to satisfy this paragraph. Home Assistant recommends Home Assistant OS for most users; it includes the managed application ecosystem and update experience. Home Assistant Container is supported, but the owner must maintain it and it does not include apps. Home Assistant: Installation

Why make the exception?

  • Device discovery and radio integrations often need host networking, D-Bus, USB devices, Bluetooth, Zigbee, Thread, or Z-Wave.
  • The official Container example uses host networking and privileged access, both of which are explicit exceptions to the baseline container policy. Home Assistant: Linux installation
  • Home automation can become more important than media without anyone noticing. Locks, leak detection, heat, lighting needed for safe movement, or alarm behavior should not share every deployment and resource failure with Plex and experiments.
  • An appliance-like HAOS instance is easier to hand to another household member and restore to replacement hardware.

Keep safety-critical devices independent of Home Assistant#

TL;DR: Preserve independent detectors, controls, manual operation, and fail-safe behavior when every networked automation component is unavailable.

Home Assistant, an LLM, a cloud account, and the home LAN must never be the sole protection for smoke, carbon monoxide, fire, medical needs, safe egress, freezing pipes, access control, or any other hazard with serious consequences. Preserve code-compliant standalone detectors and controllers, physical/manual operation, a documented fail-safe state, and a path an adult can use when Home Assistant, DNS, Wi-Fi, internet, SSO, and every model are unavailable. A smart lock still needs its independent physical or manufacturer-supported recovery path; a heating automation still needs the equipment's safe local controls and freeze protection.

Keep an LLM out of the real-time actuation loop. An agent may inventory devices, inspect redacted state, simulate logic, draft automations, and validate a staged change. It may not independently unlock or lock doors, open a garage, arm or disarm an alarm, silence a detector, change safety thresholds, disable freeze protection, operate gas or water shutoffs, or energize equipment that can injure someone or damage the house. Those actions require a present, identified adult using the product's own authenticated control, plus whatever confirmation the consequence warrants. Children and ordinary household accounts never receive administration or unrestricted access/device controls.

Before promoting any automation above convenience, conduct an attended failure test: remove internet, disable the LAN path, stop Home Assistant, and confirm the independent detector/controller, manual override, safe default, and adult recovery method still work. Record the test in the service readiness sheet. If the test cannot be performed safely, the automation cannot become a required household protection.

TL;DR: Deploy Home Assistant only for a concrete use, isolate it, add integrations incrementally, and prove backup, restore, and manual fallback.

  1. Do not deploy Home Assistant until there is a concrete device or automation to operate.
  2. If it controls only convenience devices and a tested hypervisor already exists, HAOS in a VM is acceptable while capacity is measured. Without that platform, use dedicated supported hardware or defer the service; do not redesign the server casually.
  3. If it observes anything safety-, access-, heating-, or damage-relevant, promote it to the appropriate tier and place it on UPS power. Do not let it become the sole protection or grant an agent actuation; preserve and test independent local/manual operation and fail-safe behavior before relying on it even as a supplement.
  4. Back it up automatically to storage outside the HA instance and keep the recovery key independently. Home Assistant recommends a copy on another system and ideally off-site. Home Assistant: Backups
  5. Give each adult a named user. Give household members only the dashboards and controls they need. Keep system administration separate.
  6. Test a restore into a spare VM or device before trusting important automations, and separately test the physical/manual fallback with Home Assistant offline.

Use Home Assistant Container only when the owner deliberately wants to manage every companion service, update, device mapping, and backup under Compose. Put it in its own project. Allowlist only the exact devices it uses. Do not copy privileged: true into production without first proving that narrower device and capability grants are insufficient. Host networking, if required for discovery, is a recorded exception and raises the value of host firewall testing.

Do not place Home Assistant's mobile app, webhooks, device APIs, or local recovery behind generic forward-auth without validating every client. Use Home Assistant's own authentication and keep administration on LAN/VPN. SSO convenience is secondary to local recovery.

Add media automation only after Plex is stable#

TL;DR: Add lawful media automation only after Plex paths, identities, backups, playback, and operating limits are stable.

The *Arr applications can monitor and organize libraries and can coordinate a download client. They do not make acquisition lawful. Configure them only for material the household is authorized to download, copy, or share, and comply with provider and network terms.

Do not install the whole fashionable stack at once. Add only the component that solves a stated need:

  • Radarr for movies;
  • Sonarr for television;
  • Prowlarr only when central indexer management is actually useful;
  • a supported downloader;
  • a VPN gateway only when the authorized download workflow requires it;
  • subtitle or request-management tools only after the core import path is proven.

Filesystem and identity design#

TL;DR: Arrange download and library paths so controlled hard links and atomic moves work without granting unnecessary library writes.

Use one host filesystem tree so download completion, hard linking, and atomic moves can work:

/srv/homelab/data/media/
  downloads/
    incomplete/
    complete/
  library/
    movies/
    television/

Present the same parent as /data to the downloader and the relevant *Arr importer. Do not map it as separate /downloads and /movies mounts: even if the host paths share a filesystem, separate container mounts can defeat hard links and atomic moves. Radarr's documentation calls out inconsistent container paths and separate filesystems as the two common volume failures. Radarr: Docker path guidance

Permissions remain asymmetric:

ComponentFilesystem access
PlexRead-only library; no downloads
DownloaderRead/write downloads; no library; bounded incomplete-data quota
RadarrRead/write downloads/complete and library/movies; no television library
SonarrRead/write downloads/complete and library/television; no movie library
ProwlarrIts own config only; no media or download mount
VPN gatewayIts own config and tunnel device; no media mount

Use recorded service UIDs/GIDs and a shared media-import group where needed. Verify the effective numeric identities from inside each container. Do not solve import failures with world-writable directories, disabled root squashing, or unrestricted root containers.

Keep the download VPN inside the downloader#

TL;DR: Route only downloader traffic through the VPN and fail closed without disrupting Plex, automation, ingress, or host networking.

Do not route Plex, Home Assistant, Caddy, the identity provider, or the entire host through a commercial VPN. Do not route Radarr, Sonarr, or Prowlarr through it by default. Those services need predictable LAN/API connectivity; only the downloader's external traffic belongs in the tunnel unless evidence shows otherwise.

A common contained pattern is:

  • one Gluetun VPN-gateway container with only NET_ADMIN and /dev/net/tun as its approved exceptions;
  • the downloader uses network_mode: "service:gluetun", so it has no independent network stack;
  • the VPN gateway joins the media-control Compose network;
  • Radarr/Sonarr reach the downloader API through the gateway service name and the downloader's port;
  • the downloader UI is exposed only through a loopback port on the gateway and then the LAN/VPN ingress path;
  • VPN credentials are supplied from protected secret staging through the provider-supported mechanism; they never appear in Git or chat.

Gluetun documents the shared-network-stack pattern and a firewall that acts as a kill switch when the VPN is down. Gluetun: Connect a container and Gluetun: Firewall

The acceptance test matters more than the diagram:

  1. From inside the downloader namespace, observe the expected VPN egress address.
  2. From Plex and an ordinary application, observe the normal household egress path.
  3. Stop the VPN tunnel without stopping the downloader and prove that downloader internet egress fails closed.
  4. Prove that the downloader cannot reach management subnets or another project's state.
  5. Restore the tunnel and prove queued authorized traffic resumes.
  6. Confirm no VPN control or UI port is public.

Do not proceed if the “VPN down” test leaks traffic.

Operational limits#

TL;DR: Bound bandwidth, seeding, storage, retries, notifications, and cleanup so optional downloads cannot harm lower-numbered foundational services.

  • Set download and seeding limits that preserve household upload and disk-space floors.
  • Keep incomplete downloads out of backup and out of Plex libraries.
  • Scan or otherwise validate untrusted files before import where the file type and clients warrant it.
  • Require categories so unrelated downloader jobs are not imported.
  • Let container replacement update *Arr applications; disable their in-application binary updater when the project image is the managed artifact. Radarr's Docker guidance directs container users to update the image rather than the application in place. Radarr settings
  • Back up *Arr configuration because rebuilding policies is tedious, but do not let a convenience stack's backup compete with more valuable data.
  • Tier 4 failures do not page the owner. Persistent failure appears in a digest unless it threatens data, capacity, or higher-tier performance.

Design authorized peer transfers as a separate service#

TL;DR: Do not repurpose Plex, SMB, VPN, synchronization, or backup for peer transfer until its distinct threat model is designed.

“Let another household download selected media from this one” is not the same problem as Plex streaming, media automation, synchronization, or backup. Do not improvise it by sharing a library password, exposing SMB, giving the remote user a VPN route to the whole LAN, or turning a backup repository into a file server.

Before selecting SFTP, a resumable HTTPS service, Syncthing, or another tool, write a separate readiness sheet that answers:

  • Is the transfer one-way or bidirectional?
  • Which exact files is the recipient authorized to receive?
  • Must a partial transfer resume after either house loses connectivity?
  • How is content integrity checked?
  • What sustained upload may the source use while remote streaming is active?
  • Can the recipient delete or rename the source?
  • Does the sender stage a read-only export, or expose the canonical library?
  • How are peer identity, revocation, audit, quotas, and expiration handled?
  • Does either ISP, VPN provider, or application impose relevant terms?
  • Can malicious content from the peer ever enter an application-writable path?

Recommended boundary: expose a read-only or one-way staging set, authenticate the peer with a dedicated revocable identity, rate-limit at the source, support resume and checksums, and keep the path outside management and backup authority. Finish the design only after those requirements are measured.

Phase 6 conditional exit gates#

TL;DR: Require evidence only for workloads the owner selected; do not install optional applications merely to complete the guide.

Work profile: operational size large; quota large; human effort medium; agent effort large; wait large; outage selected-service tests; only installed workloads are disrupted; clock duration unknown until target-specific evidence exists.

The Phase 5 platform gate above remains true. Apply only the following gates for workloads the owner selected. In this guide's reference build Plex is selected, so its gate is required; another reader may record it as not selected rather than installing it for completeness.

Selected-workload evidence#

TL;DR: Close each selected service only after ownership, digest, data, resources, restore, access, denial, reboot, and retirement tests pass.

  • Each selected service has a readiness sheet, owner, tier, exact image digest, data paths, resource ceiling, retirement plan, and an application-consistent restore that was actually opened.
  • In an attended test, stop and rebuild each selected optional workload, then rerun its recorded Tier 0–2 checks. A lower-numbered foundational tier must remain usable.
  • Each selected user and admin route works by durable name from the applicable clients; denied users and networks fail. A central-identity outage cannot block Tier 0/1 or the service's stored recovery path.
  • If Plex is selected, it direct-plays the representative local library, sustains the accepted one-transcode baseline, survives restart, and cannot write the canonical media library.
  • Remote Plex, if enabled, stays within its measured upload allocation while another household workload runs.
  • Home Assistant, if deployed, has an installation decision record, independent backup, restore evidence, local recovery login, and a tier matching the importance of its automations.
  • If the downloader is deployed, it loses all external egress when its VPN is stopped, while Plex and ordinary applications retain normal connectivity.
  • No peer-transfer service is exposed until its separate decision record is approved.

When to consider Kubernetes#

TL;DR: Consider Kubernetes only when observed multi-host placement, recovery, or deployment toil exceeds its additional operating cost.

Do not migrate because Kubernetes is more impressive. Migrate when recorded incidents or toil show that the single-host Compose model is the limiting factor:

  • a second application host must take work automatically;
  • cross-project startup, discovery, and restart dependencies repeatedly fail;
  • controlled rolling deployment is required;
  • placement by GPU, architecture, or site has become routine;
  • one host's maintenance window is no longer acceptable;
  • maintaining equivalent service policies by hand costs more than a small cluster.

Even then, migrate stateless and lowest-tier services first. Kubernetes can restart a container elsewhere; it cannot make an SQLite database, NFS mount, household uplink, or single disk resilient. The readiness sheets, image pins, explicit state, health tests, ingress routes, identities, backups, and agent policy developed here are what make the later move tractable.

Primary references#

TL;DR: Recheck the cited authoritative product and security documentation whenever versions, features, or deployment decisions change.