Production-ready self-hosted infrastructure running on Proxmox, managed with Infrastructure as Code (Terraform) and GitOps workflows. Currently hosting 90+ containerized services across media streaming, home automation, development tools, and AI workloads.
Memory headroom is the binding constraint. The host has ~29GB usable and typically runs at 24โ25GB. New stacks should set explicit
mem_limitvalues rather than running unbounded. (Corrected 2026-08-16: this README previously claimed 64GB. Actual is 32GB physical, ~29GB usable after the Radeon 890M iGPU carve-out.)
This repository is split into two clean domains:
infra/โ Terraform IaC for Proxmox host configuration and LXC/VM provisioningstacks/โ Docker Compose stacks deployed to LXC containers
| Resource | IP | Purpose | Specs |
|---|---|---|---|
| Proxmox Host | 172.16.1.158 |
Type-1 hypervisor | AMD Ryzen AI 9 HX PRO 370 (24 cores), 32GB RAM (~29GB usable), ZFS pool |
LXC 100 (selfhost) |
172.16.1.159 |
Primary Docker runtime | Unprivileged LXC, GPU passthrough (AMD Radeon 890M) |
VM 102 (Cerebro) |
172.16.1.160 |
AI/ML workloads | Ubuntu 24.04, GPU passthrough, Ollama, OpenClaw |
- ZFS Pool:
/mnt/fast- High-performance NVMe storage (host) - Mount into LXC/VM:
/mnt/fast/stacks(repo),/mnt/fast/appdata(persistent data) - Service Account:
apps:apps(UID/GID568:568) for container ownership
| Dataset | Mountpoint | Quota | Why |
|---|---|---|---|
fast/transcode |
/mnt/fast/transcode |
50 G | Jellyfin's transcode cache. Bound into the container at /cache/transcodes |
tank/media/Recordings |
/mnt/tank/media/Recordings |
250 GB | Dispatcharr server DVR; Jellyfin's TV Recordings library. No automatic deletion |
fast/transcode exists because an anonymous Docker volume holding Jellyfin's transcode cache
grew to 19 GB on LXC 100's root filesystem and drove / to zero mid-Phase-3. Anonymous volumes live
under /var/lib/docker/volumes โ i.e. on / โ and nothing in the compose file named them, so
nothing bounded them.
The dataset is declared in infra/jellyfin-transcode-dataset.tf and tuned for disposable sequential
data: recordsize=1M, compression=off (segments are already-compressed H.264/HEVC),
sync=disabled, atime=off.
It uses quota, not refquota, deliberately: quota counts descendants and returns ENOSPC,
which is the errno every log grep in this estate is built on. refquota returns EDQUOT and would
be invisible to them.
Standing check: scripts/check-jellyfin-transcode.sh asserts the quota, the mount shape, and
five Jellyfin retention settings, and exits non-zero on drift. It is folded into
scripts/quick-health-check.sh.
/mnt/fast/appdata itself is still not a mountpoint: it is a plain directory on LXC 100's
ext4 root, and only its children are ZFS datasets. The stacks below moved off that root on
2026-10-22 (quick task 261022-cg6). Each has its own dataset, its own LXC bind mount and a full
send to the backup pool, so the nightly incremental carries it.
| Dataset | LXC mount | recordsize | Quota | Why |
|---|---|---|---|---|
fast/appdata/teslamate |
mp32 | 16K | โ | TeslaMate Postgres (the car's whole history), Grafana, import dir. Was three named volumes on / |
fast/appdata/trek |
mp33 | 128K | โ | TREK SQLite (data/) and uploads. Was two named volumes on / |
fast/appdata/monitoring |
mp34 | 128K | 25G | Prometheus TSDB, Grafana, node-exporter textfile dir. The one continuously growing tenant, so it is bounded |
fast/appdata/airtrail |
mp35 | 16K | โ | AirTrail Postgres and uploads |
fast/appdata/silo |
mp36 | 16K | โ | Silo (dormant: silo-db and silo stay stopped) |
fast/appdata/bookorbit |
mp37 | 128K | โ | BookOrbit covers and book-bucket (data/). Created on its own dataset 2026-10-22 (261022-wp7) |
fast/appdata/bookorbit/postgres |
mp38 | 16K | โ | BookOrbit Postgres (18/docker) |
n8n's Postgres moved to /mnt/fast/appdata/automation/n8n-postgres, inside the existing
fast/appdata/automation dataset, with no new mount. It had been on /automation/n8n-postgres,
because ${APPDATA_DIR} was undefined in automation/.env.
16K follows fast/appdata/immich/postgres, the estate's other Postgres-tuned dataset. There is no
quota on the Postgres datasets on purpose: ENOSPC under Postgres WAL is a PANIC.
A new stack needs all four of these, in this order, or its state lands on /:
zfs create fast/appdata/<name>on atlantis (owner568:568, mode0755).pct set 100 -mpN /mnt/fast/appdata/<name>,mp=/mnt/fast/appdata/<name>. A newmpNhotplugs live with no LXC reboot (measured 2026-10-22). Next free indices: mp29, mp30, mp39+.- Append the matching
mount_pointblock toinfra/lxc-selfhost.tfin host index order. Neverterraform applyit (see the lifecycle note there). - Do a full
zfs send | zfs recvtobackup/appdata/<name>.backup-incremental.shskips a dataset that has no target on the backup pool, so it is not backed up until this is done.
flowchart LR
A[HDHomeRun aerial tuner] --> D[Dispatcharr]
P[IPTV provider] --> D
D -->|Live TV| T[TiviMate]
D -->|Scheduled server recording| R[Recordings storage: 250 GB]
R --> J[Jellyfin: TV Recordings]
D -->|Alternative live TV client| J
TiviMate is the preferred live-TV interface. Schedule recordings in Dispatcharr's TV Guide or DVR page; the server records even when the television is off. Watch saved programmes in Jellyfin's TV Recordings library. Recording storage has a 250,000,000,000-byte quota (about 233 GiB); reaching it stops new writes rather than deleting older recordings. The IPTV account permits one upstream channel, so recording and watching different IPTV channels simultaneously is constrained. The aerial tuner is independent.
TiviMate's Record button still uses its own recorder. This server DVR setup does
not provide live pause/rewind in TiviMate; that integration remains outstanding.
Implementation, storage mappings and verification are in the media LLD
and Dispatcharr runbook.
The recording dataset is managed by infra/dispatcharr-recordings.tf.
selfhost-stacks/
โโโ infra/ # Terraform infrastructure as code
โ โโโ host.tf # Proxmox host configuration
โ โโโ lxc-selfhost.tf # LXC 100 provisioning (Docker runtime)
โ โโโ lxc-cerebro.tf # VM 102 provisioning (AI workloads)
โ โโโ providers.tf # Terraform provider configuration
โ โโโ variables.tf # Input variables and defaults
โโโ stacks/
โ โโโ selfhosted/ # Services for LXC 100
โ โ โโโ traefik/ # Reverse proxy + SSL (core dependency)
โ โ โโโ arrs/ # Media automation (Sonarr, Radarr, Prowlarr, etc.)
โ โ โโโ media/ # Streaming (Jellyfin, Jellyseerr)
โ โ โโโ automation/ # Home automation (n8n, Oxidized, PWPush)
โ โ โโโ openwebui/ # AI chat interface
โ โ โโโ immich/ # Photo management
โ โ โโโ code-server/ # Web-based VS Code
โ โ โโโ ... # 15+ additional stacks
โ โโโ cerebro/ # Services for VM 102
โ โโโ borg/ # OpenClaw AI gateway
โโโ scripts/ # Operational utilities
โ โโโ pg-migrate.sh # PostgreSQL major-version migration (dump/restore)
โ โโโ setup-teleport.sh # Teleport agent bootstrap
โ โโโ check-renovate.sh # Renovate dependency validation
โโโ .github/
โโโ workflows/
โโโ compose-validate.yaml # CI: Validate compose files on PRs
- Proxmox VE 8.x+ installed on host
- Terraform 1.10+ on your local machine
- SSH access to Proxmox host (
root@172.16.1.158)
Deploy Proxmox host configuration and provision LXC containers:
cd infra
terraform init
terraform plan
terraform applyWhat this does:
- Configures ZFS storage pool on Proxmox host
- Creates unprivileged LXC
100(selfhost) with Docker, GPU passthrough, and mounts - Provisions VM
102(Cerebro) with Ubuntu 24.04, GPU passthrough, Ollama, and OpenClaw - Sets up service account (
apps:apps) and permissions - Maps AMD Radeon 890M GPU devices (
video:44,render:110)
SSH into the LXC and deploy stacks from the repository:
# On your local machine - sync repository to server
ssh root@172.16.1.159
cd /mnt/fast/stacks
git pull origin main
# Deploy foundational stack (Traefik reverse proxy)
docker compose -f stacks/selfhosted/traefik/compose.yaml up -d
# Deploy application stacks
docker compose -f stacks/selfhosted/arrs/compose.yaml up -d
docker compose -f stacks/selfhosted/media/compose.yaml up -d
docker compose -f stacks/selfhosted/automation/compose.yaml up -d
docker compose -f stacks/selfhosted/openwebui/compose.yaml up -dCompose files reference:
- Each stack is modular and can be deployed independently
- Stacks share the
t3_proxynetwork managed by Traefik - Environment variables stored in
.envfiles (use.env.sampletemplates)
This repository uses Renovate for automated dependency updates:
- โ Auto-merge patch updates (1.2.3 โ 1.2.4) after 1-hour stability period
- โ Auto-merge minor updates (1.2.0 โ 1.3.0) after 3-hour stability period
โ ๏ธ Manual review for major updates (1.x โ 2.x) to prevent breaking changes- ๐ Security updates merge immediately regardless of stability period
Configuration: See renovate.json
Dashboard: Dependency Dashboard Issue #3 - View all pending updates
- Pinned versions (e.g.,
jellyfin:10.11.6) are tracked by Renovate and auto-updated :latesttags are ignored by Renovate (manual updates required)- Branch tags (
:develop,:stable) are ignored by Renovate
Best Practice: Pin to specific semver tags to enable automatic updates while maintaining stability.
GitHub Actions automatically validate compose files on every pull request:
- โ
Syntax validation with
docker compose config - โ Image availability checks
- โ Environment variable reference validation
- โ Prevents broken configurations from merging
Workflow: .github/workflows/compose-validate.yaml
- Create compose file in
stacks/selfhosted/<stack-name>/compose.yaml - Pin container versions (avoid
:latest) - Create
.env.sampletemplate for required variables - Document service in compose file comments
- Commit and push to GitHub
- SSH to LXC and deploy:
docker compose -f stacks/selfhosted/<stack-name>/compose.yaml up -d
PostgreSQL major version upgrades (e.g. PG17 โ PG18) require a dump/restore cycle โ data is not forward-compatible. All postgres volumes now mount at /var/lib/postgresql (PG18 layout); data lives in a versioned subdir (18/docker/).
ssh root@172.16.1.159
cd /mnt/fast/stacks
# Pull updated compose files (with new image tags)
git pull
# Run the migration script for a single instance
bash scripts/pg-migrate.sh n8n
# Or migrate all instances in correct order
bash scripts/pg-migrate.sh allThe script handles dump, old-data removal, PG18 init, restore, and service restart. Backups are retained in /tmp/pg-backups/ on the host.
Note: pgvector/pgvector images are blocked from Renovate major-version auto-merge โ they must be migrated manually using the same procedure.
ssh root@172.16.1.159
cd /mnt/fast/stacks
git pull origin main
docker compose -f stacks/selfhosted/<stack-name>/compose.yaml pull
docker compose -f stacks/selfhosted/<stack-name>/compose.yaml up -ddocker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"docker compose -f stacks/selfhosted/<stack-name>/compose.yaml logs -fcd infra
terraform plan
terraform apply- SSL/TLS: Automatic certificate management with Let's Encrypt
- Authentication: Authelia integration for protected services
- Middleware: Rate limiting, IP whitelisting, basic auth available
- Public: No authentication (e.g., Jellyfin for family, PWPush for sharing)
- Protected: Authelia SSO (admin dashboards, management interfaces)
- Internal Only: No Traefik route, LXC network only (databases, caches)
Sensitive data stored in .env files (gitignored):
- Database passwords
- API keys
- Secret keys
- JWT tokens
Template files: .env.sample committed to repository as documentation
AMD Radeon 890M GPU is passed through to LXC 100:
Device Mapping:
/dev/dri/renderD128(grouprender:110)/dev/dri/card1(groupvideo:44)
Configuration: See infra/lxc-selfhost.tf for LXC device mapping
Services:
- Immich: hardware-accelerated photo/video processing โ active
- Jellyfin:
โ ๏ธ hardware transcoding is DISABLED, deliberately. Jellyfin transcodes in software (libx264)./dev/driis still mapped into the container, but Jellyfin does not use it.
Jellyfin's live config is
HardwareAccelerationType: noneandEnableHardwareEncoding: false. This is the D-30 amdgpu mitigation, applied after the 2026-08-31 outage that cost ~6 hours.The failure mode is a kernel oops in the
amdgpuHMM path triggered by GPU userptr mappings. Its blast radius is counter-intuitive:dockerdreads/proc/*/smaps, so Traefik and every*.deercrest.infoservice die while direct-macvlan containers keep serving. It presents as/proc/pressure/iofullโ 96% with an idle CPU and kerneltaintedbit 7 โ not as load average, which lags and false-greens after reboot. Proxmox kernel 7.0.14-8 does not carry a fix.High CPU during a Jellyfin transcode is therefore correct behaviour, not a fault to investigate.
scripts/check-jellyfin-transcode.shreports both values on every run so drift is visible;scripts/disable-jellyfin-hwaccel.shis the recovery lever.
scripts/quick-health-check.sh is the estate's single entry point. Run it from the workstation;
it ssh's to LXC 100 and folds in four subordinate checks:
bash scripts/quick-health-check.sh # exits 0 healthy, 1 on any violation
REMOTE_TIMEOUT=300 bash scripts/quick-health-check.sh # slower link / busy host| Check | Asserts |
|---|---|
check-music-freeze.sh |
Jellyfin's Music library cannot write .nfo/.jpg/.lrc into the library |
check-music-consumers.sh |
the NFS export and its consumers; Jellyfin addressing facts |
check-jellyfin-transcode.sh |
the 50 G transcode quota, the mount shape, and five Jellyfin retention values |
check-drift.sh |
which running containers are on an image tag git no longer pins; the unresolvable-pin set; unhealthy and created containers |
They exist because a previous generation of checks printed a green tick while blind. Three rules came out of that, and new checks should honour them:
- Fail closed, and keep "could not look" distinct from "nothing is wrong." A check that cannot
reach the host reports
UNKNOWNand exits non-zero โ never0 problems found. - Bound every remote command Linux-side. The workstation is macOS and has no GNU
timeout, so bounds go inside the remote command string (REMOTE_TIMEOUT, default 120s). A killed command surfaces as its own condition via exit124.โ ๏ธ timeout N cmd | wc -ldoes not work โtimeoutsignals only the first stage,wccounts the empty stream and the pipeline exits0. The remote string needsset -o pipefailand the ssh return code must be read.${PIPESTATUS[0]}does not rescue it: an assignment is a simple command, not a pipeline. - Assert, don't report. A value that is merely printed cannot fail the run. Anything that must
not drift gets an explicit expectation and increments
FAILURESwhen it moves.
Anything asserted must also be proven able to fail โ drive it red once, deliberately, rather
than trusting that it would. Every EXPECT_* in check-jellyfin-transcode.sh is env-overridable
precisely so a negative control needs no file edit.
-
If a check runs unattended, its own staleness is part of what it asserts. The other three only ever fire when somebody types them, so "did it run?" is answered by the fact that you are reading the output.
check-drift.shalso runs on an hourly systemd timer, writing to a node-exporter textfile โ and a timer that dies produces silence that looks exactly like health, with the last measurement frozen in place and every downstream rule quietly reading a snapshot. So it emitsselfhost_image_drift_last_success_timestamp_seconds, writes it only on a successful run, and leaves the previous file byte-identical on a failure so it goes stale rather than fresh-and-wrong. A dedicated Grafana rule alerts on that staleness, and all three rules carrynoDataState: Alertingso an absent metric alerts instead of going quiet.Note rule 3 is deliberately not applied to the drift count itself: v1 is alert-only, so asserting it would make this entry point permanently red for a condition nothing in the repo can clear. See
IMAGE_DRIFT_PROMOTEDinquick-health-check.sh.
- Jellyfin - Media server (movies, TV, music)
- Jellyseerr - Content request management
- Jellystat - Analytics and statistics
- Wizarr - User invitation system
- Sonarr - TV show management
- Radarr - Movie management
- Lidarr - Music management
- Bazarr - Subtitle management
- Prowlarr - Indexer manager
- Autobrr - IRC announce grabber
- Bookshelf (container
readarr) - Book and audiobook management; Readarr fork on the hardcover (Hardcover.app) flavour since 2026-10-22; metadata from the self-hosted rreading-glasses (internal only,http://rreading-glasses:8788) since 2026-10-23
- BookOrbit - Ebook and audiobook reader at
books.deercrest.info, own login, library read-only (replaced Booklore 2026-10-22; seestacks/selfhosted/bookorbit/README.md) - Audiobookshelf - Audiobooks and podcasts at
audiobooks.deercrest.info
- Code Server - Web-based VS Code
- OpenWebUI - AI chat interface (Ollama frontend)
- n8n - Workflow automation
- Oxidized - Network device backup
- Teleport - Access control proxy
- Immich - Google Photos alternative
- Traefik - Reverse proxy and SSL termination
- Authelia - SSO authentication
- Docker Socket Proxy - Secure Docker API access
See full inventory: 70+ containers across 20+ stacks
- CLAUDE.md - AI Agent guidance for repository operations
- DEPLOY.md - Detailed deployment procedures
- STANDARDS.md - Coding and configuration standards
docker compose -f stacks/selfhosted/<stack>/compose.yaml logsEnsure files are owned by apps:apps (568:568):
chown -R 568:568 /mnt/fast/appdata/<stack>Verify device mapping in LXC:
ls -l /dev/dri/
groups apps # Should include 'video' and 'render'Check Traefik dashboard: https://traefik.deercrest.info
Check Dependency Dashboard for ignored or blocked updates
- Pin container versions (avoid
:latest) - Include
.env.samplefor new stacks - Document service purpose in compose file comments
- Test compose validation locally:
docker compose config - Create descriptive commit messages
Private repository - All rights reserved
Maintained by: Damian Flynn
Last Updated: June 2026
Repository: github.com/DamianFlynn/selfhost-stacks